From 8a4bf1edf3c47bf22c6dd48fd219727411aa8651 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:27:36 +0800 Subject: [PATCH 01/31] feat(api): retire Files API routes --- crates/api/src/lib.rs | 17 +- crates/api/src/openapi.rs | 9 - crates/api/src/routes/files.rs | 16 + crates/api/tests/e2e_all/api_keys.rs | 12 +- crates/api/tests/e2e_all/files.rs | 1202 ++----------------------- crates/api/tests/e2e_all/vpc_login.rs | 6 +- 6 files changed, 130 insertions(+), 1132 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 374dc8673..895d2dcec 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1874,15 +1874,18 @@ pub fn build_workspace_routes(app_state: AppState, auth_state_middleware: &AuthS )) } -/// Build file upload routes +/// Build the retired Files API surface. +/// +/// Keep the authenticated route boundary while preventing every Files request +/// from reaching the legacy service, storage, or repository layers. The +/// dormant wiring is intentionally retained until the follow-up cleanup. pub fn build_files_routes(app_state: AppState, auth_state_middleware: &AuthState) -> Router { - use crate::routes::files::MAX_FILE_SIZE; - use crate::routes::files::*; + use crate::routes::files::files_api_deprecated; + Router::new() - .route("/files", post(upload_file).get(list_files)) - .route("/files/{file_id}", get(get_file).delete(delete_file)) - .route("/files/{file_id}/content", get(get_file_content)) - .layer(DefaultBodyLimit::max(MAX_FILE_SIZE)) + .route("/files", axum::routing::any(files_api_deprecated)) + .route("/files/", axum::routing::any(files_api_deprecated)) + .route("/files/{*path}", axum::routing::any(files_api_deprecated)) .with_state(app_state) .layer(from_fn_with_state( auth_state_middleware.clone(), diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 7b55969b3..fd2f54c19 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -30,7 +30,6 @@ use utoipa::{Modify, OpenApi}; (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), - (name = "Files", description = "File upload and management"), (name = "Users", description = "User profile and token management"), (name = "Invitations", description = "Token-based invitation handling"), (name = "Usage", description = "Usage tracking and billing information"), @@ -102,12 +101,6 @@ use utoipa::{Modify, OpenApi}; crate::routes::workspaces::revoke_workspace_api_key, crate::routes::workspaces::update_api_key_spend_limit, crate::routes::workspaces::update_workspace_api_key, - // Files endpoints - crate::routes::files::upload_file, - crate::routes::files::list_files, - crate::routes::files::get_file, - crate::routes::files::delete_file, - crate::routes::files::get_file_content, // Users endpoints crate::routes::users::get_current_user, crate::routes::users::get_user_status, @@ -346,8 +339,6 @@ use utoipa::{Modify, OpenApi}; crate::routes::billing::BillingCostsRequest, crate::routes::billing::BillingCostsResponse, crate::routes::billing::RequestCost, - // File models - FileUploadResponse, ExpiresAfter, FileListResponse, FileDeleteResponse, // Platform Stats analytics models services::admin::PlatformMetrics, services::admin::PlatformProviderUsage, diff --git a/crates/api/src/routes/files.rs b/crates/api/src/routes/files.rs index 2489dc818..a40095a81 100644 --- a/crates/api/src/routes/files.rs +++ b/crates/api/src/routes/files.rs @@ -15,6 +15,22 @@ use uuid::Uuid; pub const MAX_FILE_SIZE: usize = 512 * 1024 * 1024; // 512 MB +/// Returns the documented retirement response for the former Files API. +/// +/// The service and its stored data remain in place for now so existing data can +/// be handled by a separate retention effort, but no Files API request may +/// trigger a new upload, read, or deletion. +pub async fn files_api_deprecated() -> (StatusCode, Json) { + ( + StatusCode::GONE, + Json(ErrorResponse::new( + "The Files API has been deprecated and is no longer available. Manage file content in your application and use stateless POST /v1/responses requests with store: false." + .to_string(), + "gone".to_string(), + )), + ) +} + #[utoipa::path( post, path = "/v1/files", diff --git a/crates/api/tests/e2e_all/api_keys.rs b/crates/api/tests/e2e_all/api_keys.rs index 91051415f..687e6b10c 100644 --- a/crates/api/tests/e2e_all/api_keys.rs +++ b/crates/api/tests/e2e_all/api_keys.rs @@ -390,7 +390,7 @@ async fn test_deleted_api_key_cannot_be_used() { let api_key = api_key_resp.key.clone().unwrap(); - // Verify key works before deletion + // A valid key reaches the retired Files endpoint before deletion. let response = server .get("/v1/files?limit=1") .add_header("Authorization", format!("Bearer {api_key}")) @@ -398,8 +398,8 @@ async fn test_deleted_api_key_cannot_be_used() { assert_eq!( response.status_code(), - 200, - "API key should work before deletion" + 410, + "Valid API key should reach the Files API retirement response before deletion" ); // Delete the API key @@ -895,7 +895,7 @@ async fn test_api_key_authentication() { let (api_key, _) = create_org_and_api_key(&server).await; - // Test valid API key + // A valid API key reaches the retired Files endpoint. let response = server .get("/v1/files?limit=1") .add_header("Authorization", format!("Bearer {api_key}")) @@ -903,8 +903,8 @@ async fn test_api_key_authentication() { assert_eq!( response.status_code(), - 200, - "Valid API key should be accepted" + 410, + "Valid API key should reach the Files API retirement response" ); // Test invalid API key diff --git a/crates/api/tests/e2e_all/files.rs b/crates/api/tests/e2e_all/files.rs index cfcc37d3d..ef7f45b95 100644 --- a/crates/api/tests/e2e_all/files.rs +++ b/crates/api/tests/e2e_all/files.rs @@ -1,1155 +1,143 @@ -// Import common test utilities - use crate::common::*; -use services::id_prefixes::PREFIX_FILE; - -/// Helper function to upload a file -async fn upload_file( - server: &axum_test::TestServer, - api_key: &str, - filename: &str, - content: &[u8], - content_type: &str, - purpose: &str, -) -> axum_test::TestResponse { - server - .post("/v1/files") - .add_header("Authorization", format!("Bearer {api_key}")) - .multipart( - axum_test::multipart::MultipartForm::new() - .add_text("purpose", purpose) - .add_part( - "file", - axum_test::multipart::Part::bytes(content.to_vec()) - .file_name(filename) - .mime_type(content_type), - ), - ) - .await -} - -/// Helper function to upload a file with expiration -async fn upload_file_with_expiration( - server: &axum_test::TestServer, - api_key: &str, - filename: &str, - content: &[u8], - content_type: &str, - purpose: &str, - expires_after_seconds: i64, -) -> axum_test::TestResponse { - server - .post("/v1/files") - .add_header("Authorization", format!("Bearer {api_key}")) - .multipart( - axum_test::multipart::MultipartForm::new() - .add_text("purpose", purpose) - .add_text("expires_after[anchor]", "created_at") - .add_text("expires_after[seconds]", expires_after_seconds.to_string()) - .add_part( - "file", - axum_test::multipart::Part::bytes(content.to_vec()) - .file_name(filename) - .mime_type(content_type), - ), - ) - .await -} - -#[tokio::test] -async fn test_upload_file_success() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let content = b"Hello, this is a test file!"; - let response = upload_file( - &server, - &api_key, - "test.txt", - content, - "text/plain", - "user_data", - ) - .await; - - assert_eq!(response.status_code(), 201); - let file: api::models::FileUploadResponse = response.json(); - assert!(file.id.starts_with(PREFIX_FILE)); - assert_eq!(file.object, "file"); - assert_eq!(file.bytes, content.len() as i64); - assert_eq!(file.filename, "test.txt"); - assert_eq!(file.purpose, "user_data"); - assert!(file.created_at > 0); - assert!(file.expires_at.is_none()); -} - -#[tokio::test] -async fn test_upload_file_with_expiration() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let content = b"Temporary file"; - let expires_after_seconds = 86400; // 1 day - let response = upload_file_with_expiration( - &server, - &api_key, - "temp.txt", - content, - "text/plain", - "user_data", - expires_after_seconds, - ) - .await; - - assert_eq!(response.status_code(), 201); - let file: api::models::FileUploadResponse = response.json(); - assert!(file.expires_at.is_some()); - assert!(file.expires_at.unwrap() > file.created_at); -} -#[tokio::test] -async fn test_upload_json_file() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; +const FILES_API_GONE_MESSAGE: &str = + "The Files API has been deprecated and is no longer available. Manage file content in your application and use stateless POST /v1/responses requests with store: false."; - let content = br#"{"key": "value", "number": 42}"#; - let response = upload_file( - &server, - &api_key, - "data.json", - content, - "application/json", - "user_data", - ) - .await; +fn assert_files_api_is_gone(response: axum_test::TestResponse) { + assert_eq!(response.status_code(), 410); - assert_eq!(response.status_code(), 201); - let file: api::models::FileUploadResponse = response.json(); - assert_eq!(file.filename, "data.json"); - assert_eq!(file.bytes, content.len() as i64); + let error = response.json::(); + assert_eq!(error.error.r#type, "gone"); + assert_eq!(error.error.message, FILES_API_GONE_MESSAGE); } #[tokio::test] -async fn test_upload_binary_file() { +async fn test_files_api_returns_gone_for_all_legacy_routes() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; - let content = vec![0u8, 1, 2, 3, 4, 5, 255, 254, 253]; - let response = upload_file( - &server, - &api_key, - "binary.bin", - &content, - "application/octet-stream", - "user_data", - ) - .await; - - assert_eq!(response.status_code(), 201); - let file: api::models::FileUploadResponse = response.json(); - assert_eq!(file.bytes, content.len() as i64); -} - -#[tokio::test] -async fn test_upload_file_invalid_purpose() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; + assert_files_api_is_gone( + server + .post("/v1/files") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({"purpose": "user_data"})) + .await, + ); - let content = b"Test content"; - let response = upload_file( - &server, - &api_key, - "test.txt", - content, - "text/plain", - "invalid_purpose", - ) - .await; + assert_files_api_is_gone( + server + .get("/v1/files?limit=1") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); - assert_eq!(response.status_code(), 400); - let error: api::models::ErrorResponse = response.json(); - assert!(error.error.message.contains("Invalid file purpose")); -} + assert_files_api_is_gone( + server + .get("/v1/files/") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); -#[tokio::test] -async fn test_upload_file_invalid_mime_type() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; + assert_files_api_is_gone( + server + .get("/v1/files/file-00000000-0000-0000-0000-000000000000") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); - let content = b"Test content"; - let response = upload_file( - &server, - &api_key, - "test.exe", - content, - "application/x-msdownload", - "user_data", - ) - .await; + assert_files_api_is_gone( + server + .delete("/v1/files/file-00000000-0000-0000-0000-000000000000") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); - assert_eq!(response.status_code(), 400); - let error: api::models::ErrorResponse = response.json(); - assert!(error.error.message.contains("Invalid file type")); + assert_files_api_is_gone( + server + .get("/v1/files/file-00000000-0000-0000-0000-000000000000/content") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); } #[tokio::test] -async fn test_upload_file_missing_purpose() { +async fn test_files_api_returns_gone_for_other_methods_and_subpaths() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; - let response = server - .post("/v1/files") - .add_header("Authorization", format!("Bearer {api_key}")) - .multipart( - axum_test::multipart::MultipartForm::new().add_part( - "file", - axum_test::multipart::Part::bytes(b"Test content".to_vec()) - .file_name("test.txt") - .mime_type("text/plain"), - ), - ) - .await; - - assert_eq!(response.status_code(), 400); - let error: api::models::ErrorResponse = response.json(); - assert!(error - .error - .message - .contains("Missing required field: purpose")); -} - -#[tokio::test] -async fn test_upload_file_unauthorized() { - let server = setup_test_server().await; - - let content = b"Test content"; - let response = upload_file( - &server, - "invalid-api-key", - "test.txt", - content, - "text/plain", - "user_data", - ) - .await; + assert_files_api_is_gone( + server + .patch("/v1/files/file-00000000-0000-0000-0000-000000000000") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); - assert_eq!(response.status_code(), 401); + assert_files_api_is_gone( + server + .put("/v1/files/legacy/nested/path") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); } #[tokio::test] -async fn test_list_files() { +async fn test_files_api_requires_authentication_before_returning_gone() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; - // Upload multiple files - for i in 1..=5 { - let content = format!("File content {i}"); - upload_file( - &server, - &api_key, - &format!("file{i}.txt"), - content.as_bytes(), - "text/plain", - "user_data", - ) - .await; - } + assert_files_api_is_gone( + server + .get("/v1/files") + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); - // List files - let response = server + let invalid_key_response = server .get("/v1/files") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let list: api::models::FileListResponse = response.json(); - assert_eq!(list.object, "list"); - assert!(list.data.len() >= 5); - assert!(list.first_id.is_some()); - assert!(list.last_id.is_some()); -} - -#[tokio::test] -async fn test_list_files_with_limit() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload multiple files - for i in 1..=5 { - let content = format!("File content {i}"); - upload_file( - &server, - &api_key, - &format!("file{i}.txt"), - content.as_bytes(), - "text/plain", - "user_data", - ) - .await; - } - - // List with limit - let response = server - .get("/v1/files?limit=2") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let list: api::models::FileListResponse = response.json(); - assert_eq!(list.data.len(), 2); - assert!(list.has_more); -} - -#[tokio::test] -async fn test_list_files_with_pagination() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload multiple files - for i in 1..=5 { - let content = format!("File content {i}"); - upload_file( - &server, - &api_key, - &format!("file{i}.txt"), - content.as_bytes(), - "text/plain", - "user_data", - ) - .await; - } - - // Get first page - let response = server - .get("/v1/files?limit=2") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let first_page: api::models::FileListResponse = response.json(); - assert_eq!(first_page.data.len(), 2); - - // Get second page using cursor - let after_id = first_page.last_id.unwrap(); - let response = server - .get(&format!("/v1/files?limit=2&after={after_id}")) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let second_page: api::models::FileListResponse = response.json(); - assert!(!second_page.data.is_empty()); - // Ensure we got different files - assert_ne!(first_page.data[0].id, second_page.data[0].id); -} - -#[tokio::test] -async fn test_list_files_with_purpose_filter() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload files with different purposes - upload_file( - &server, - &api_key, - "assistants_file.txt", - b"Assistants content", - "text/plain", - "assistants", - ) - .await; - - upload_file( - &server, - &api_key, - "user_file.txt", - b"User content", - "text/plain", - "user_data", - ) - .await; - - // List only assistants files - let response = server - .get("/v1/files?purpose=assistants") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let list: api::models::FileListResponse = response.json(); - for file in &list.data { - assert_eq!(file.purpose, "assistants"); - } -} - -#[tokio::test] -async fn test_list_files_with_order() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload multiple files - for i in 1..=3 { - let content = format!("File content {i}"); - upload_file( - &server, - &api_key, - &format!("file{i}.txt"), - content.as_bytes(), - "text/plain", - "user_data", - ) - .await; - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; // Ensure different timestamps - } - - // List in ascending order - let response = server - .get("/v1/files?order=asc") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let asc_list: api::models::FileListResponse = response.json(); - - // List in descending order - let response = server - .get("/v1/files?order=desc") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let desc_list: api::models::FileListResponse = response.json(); - - // Verify order is different - if asc_list.data.len() >= 2 && desc_list.data.len() >= 2 { - assert!(asc_list.data[0].created_at <= asc_list.data[1].created_at); - assert!(desc_list.data[0].created_at >= desc_list.data[1].created_at); - } -} - -#[tokio::test] -async fn test_get_file_metadata() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload a file - let content = b"Test file content"; - let upload_response = upload_file( - &server, - &api_key, - "metadata_test.txt", - content, - "text/plain", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let uploaded_file: api::models::FileUploadResponse = upload_response.json(); - - // Get file metadata - let response = server - .get(&format!("/v1/files/{}", uploaded_file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let file: api::models::FileUploadResponse = response.json(); - assert_eq!(file.id, uploaded_file.id); - assert_eq!(file.filename, "metadata_test.txt"); - assert_eq!(file.bytes, content.len() as i64); - assert_eq!(file.purpose, "user_data"); -} - -#[tokio::test] -async fn test_get_file_metadata_not_found() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let response = server - .get("/v1/files/file-00000000-0000-0000-0000-000000000000") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 404); -} - -#[tokio::test] -async fn test_get_file_content() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload a file - let content = b"This is the file content that should be returned"; - let upload_response = upload_file( - &server, - &api_key, - "content_test.txt", - content, - "text/plain", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let uploaded_file: api::models::FileUploadResponse = upload_response.json(); - - // Get file content - let response = server - .get(&format!("/v1/files/{}/content", uploaded_file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - - // Verify headers - let content_type = response - .headers() - .get("content-type") - .unwrap() - .to_str() - .unwrap(); - assert_eq!(content_type, "text/plain"); - - let content_length = response - .headers() - .get("content-length") - .unwrap() - .to_str() - .unwrap(); - assert_eq!(content_length, content.len().to_string()); - - let content_disposition = response - .headers() - .get("content-disposition") - .unwrap() - .to_str() - .unwrap(); - assert!(content_disposition.contains("content_test.txt")); - - // Verify content - let body = response.as_bytes(); - assert_eq!(body.as_ref(), &content[..]); -} - -#[tokio::test] -async fn test_get_binary_file_content() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload a binary file - let content = vec![0u8, 1, 2, 3, 4, 5, 255, 254, 253]; - let upload_response = upload_file( - &server, - &api_key, - "binary.bin", - &content, - "application/octet-stream", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let uploaded_file: api::models::FileUploadResponse = upload_response.json(); - - // Get file content - let response = server - .get(&format!("/v1/files/{}/content", uploaded_file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - - // Verify binary content matches exactly - let body = response.as_bytes(); - assert_eq!(body.to_vec(), content); -} - -#[tokio::test] -async fn test_get_file_content_not_found() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let response = server - .get("/v1/files/file-00000000-0000-0000-0000-000000000000/content") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 404); -} - -#[tokio::test] -async fn test_delete_file() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload a file - let content = b"File to be deleted"; - let upload_response = upload_file( - &server, - &api_key, - "delete_test.txt", - content, - "text/plain", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let uploaded_file: api::models::FileUploadResponse = upload_response.json(); - - // Delete the file - let response = server - .delete(&format!("/v1/files/{}", uploaded_file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 200); - let delete_response: api::models::FileDeleteResponse = response.json(); - assert_eq!(delete_response.id, uploaded_file.id); - assert_eq!(delete_response.object, "file"); - assert!(delete_response.deleted); - - // Verify file is deleted - let response = server - .get(&format!("/v1/files/{}", uploaded_file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 404); -} - -#[tokio::test] -async fn test_delete_file_not_found() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let response = server - .delete("/v1/files/file-00000000-0000-0000-0000-000000000000") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(response.status_code(), 404); -} - -#[tokio::test] -async fn test_file_isolation_between_workspaces() { - let server = setup_test_server().await; - - // Create two organizations with API keys - let org1 = create_org(&server).await; - let api_key1 = get_api_key_for_org(&server, org1.id.clone()).await; - - let org2 = create_org(&server).await; - let api_key2 = get_api_key_for_org(&server, org2.id.clone()).await; - - // Upload file with first workspace - let content = b"Workspace 1 file"; - let upload_response = upload_file( - &server, - &api_key1, - "workspace1.txt", - content, - "text/plain", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let file1: api::models::FileUploadResponse = upload_response.json(); - - // Try to access with second workspace - should fail - let response = server - .get(&format!("/v1/files/{}", file1.id)) - .add_header("Authorization", format!("Bearer {api_key2}")) - .await; - - assert_eq!(response.status_code(), 404); - - // Try to delete with second workspace - should fail - let response = server - .delete(&format!("/v1/files/{}", file1.id)) - .add_header("Authorization", format!("Bearer {api_key2}")) - .await; - - assert_eq!(response.status_code(), 404); - - // Verify first workspace can still access - let response = server - .get(&format!("/v1/files/{}", file1.id)) - .add_header("Authorization", format!("Bearer {api_key1}")) - .await; - - assert_eq!(response.status_code(), 200); -} - -#[tokio::test] -async fn test_upload_and_download_large_file() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a 1MB file - let content = vec![42u8; 1024 * 1024]; - let upload_response = upload_file( - &server, - &api_key, - "large_file.bin", - &content, - "application/octet-stream", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let uploaded_file: api::models::FileUploadResponse = upload_response.json(); - assert_eq!(uploaded_file.bytes, content.len() as i64); - - // Download and verify - let response = server - .get(&format!("/v1/files/{}/content", uploaded_file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) + .add_header("Authorization", "Bearer invalid_key_12345") .await; + assert_eq!(invalid_key_response.status_code(), 401); - assert_eq!(response.status_code(), 200); - let body = response.as_bytes(); - assert_eq!(body.len(), content.len()); - assert_eq!(body.to_vec(), content); + let missing_key_response = server.get("/v1/files").await; + assert_eq!(missing_key_response.status_code(), 401); } #[tokio::test] -async fn test_file_id_formats() { +async fn test_openapi_does_not_advertise_files_api() { let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Upload a file - let content = b"Test content"; - let upload_response = upload_file( - &server, - &api_key, - "test.txt", - content, - "text/plain", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let uploaded_file: api::models::FileUploadResponse = upload_response.json(); - - // Test with file prefix - let response = server - .get(&format!("/v1/files/{}", uploaded_file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response.status_code(), 200); - - // Test without file prefix (strip prefix from ID) - let id_without_prefix = uploaded_file.id.strip_prefix(PREFIX_FILE).unwrap(); - let response = server - .get(&format!("/v1/files/{id_without_prefix}")) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response.status_code(), 200); -} - -#[tokio::test] -async fn test_complete_file_lifecycle() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // 1. Upload file - let content = b"Complete lifecycle test"; - let upload_response = upload_file( - &server, - &api_key, - "lifecycle.txt", - content, - "text/plain", - "user_data", - ) - .await; - assert_eq!(upload_response.status_code(), 201); - let file: api::models::FileUploadResponse = upload_response.json(); - - // 2. List files (should include our file) - let list_response = server - .get("/v1/files") - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(list_response.status_code(), 200); - let list: api::models::FileListResponse = list_response.json(); - assert!(list.data.iter().any(|f| f.id == file.id)); - - // 3. Get metadata - let get_response = server - .get(&format!("/v1/files/{}", file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(get_response.status_code(), 200); - - // 4. Download content - let content_response = server - .get(&format!("/v1/files/{}/content", file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(content_response.status_code(), 200); - assert_eq!(content_response.as_bytes().as_ref(), &content[..]); - - // 5. Delete file - let delete_response = server - .delete(&format!("/v1/files/{}", file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(delete_response.status_code(), 200); - - // 6. Verify deletion - let get_response = server - .get(&format!("/v1/files/{}", file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(get_response.status_code(), 404); -} - -#[tokio::test] -async fn test_file_in_response_api() { - let (server, _pool, mock, _database) = setup_test_server_with_pool().await; - let model_id = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Configure mock provider with exact prompt matchers - // Timestamps will be normalized automatically to [TIME] for matching - use crate::common::mock_prompts; - - // First request - with file content - let first_prompt = mock_prompts::build_prompt( - "Tell me more about yourself.\n\nFile: test_doc.txt\nContent:\nMichael Jordan is widely regarded as one of the greatest basketball players of all time. He won six NBA championships and was known for his scoring and competitiveness." - ); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt(first_prompt)) - .respond_with(inference_providers::mock::ResponseTemplate::new( - "Michael Jordan is indeed a legendary basketball player! He's widely regarded as one of the greatest players of all time, having won six NBA championships with the Chicago Bulls." - )) - .await; - - // Second request - conversation history includes first message with file + new question - let second_prompt = mock_prompts::build_prompt( - "Tell me more about yourself.\nFile: test_doc.txt\nContent:\nMichael Jordan is widely regarded as one of the greatest basketball players of all time. He won six NBA championships and was known for his scoring and competitiveness. Michael Jordan is indeed a legendary basketball player! He's widely regarded as one of the greatest players of all time, having won six NBA championships with the Chicago Bulls. What does the file say?" - ); - let expected_response = "The file contains information about Michael Jordan, discussing his greatness as a basketball player and his six NBA championships."; - - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - second_prompt, - )) - .respond_with(inference_providers::mock::ResponseTemplate::new( - expected_response, - )) - .await; - - // 1. Upload a text file - let file_content = b"Michael Jordan is widely regarded as one of the greatest basketball players of all time. He won six NBA championships and was known for his scoring and competitiveness."; - let upload_response = upload_file( - &server, - &api_key, - "test_doc.txt", - file_content, - "text/plain", - "user_data", - ) - .await; - - assert_eq!(upload_response.status_code(), 201); - let file: api::models::FileUploadResponse = upload_response.json(); - println!("Uploaded file: {}", file.id); - - // Get file to check if it exists - let file_response = server - .get(&format!("/v1/files/{}", file.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(file_response.status_code(), 200); - let file_obj: api::models::FileUploadResponse = file_response.json(); - println!("File: {file_obj:?}"); - - // 2. Create a conversation - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(conversation_response.status_code(), 201); - let conversation: api::models::ConversationObject = conversation_response.json(); - println!("Created conversation: {}", conversation.id); - - // 4. Create a response with file input (non-streaming) - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": model_id, - "conversation": conversation.id, - "input": [{ - "role": "user", - "content": [{ - "type": "input_text", - "text": "Tell me more about yourself." - }, { - "type": "input_file", - "file_id": file.id - }] - }], - "max_output_tokens": 100, - "stream": false - })) - .await; + let response = server.get("/api-docs/openapi.json").await; assert_eq!(response.status_code(), 200); - let response_obj: api::models::ResponseObject = response.json(); - - // 5. Verify the response completed successfully - assert_eq!(response_obj.status, api::models::ResponseStatus::Completed); - - // 6. Verify the response has output - assert!(!response_obj.output.is_empty()); - - // 7. Check that input items were stored (should include file reference) - let input_items_response = server - .get(&format!("/v1/responses/{}/input_items", response_obj.id)) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - if input_items_response.status_code() != 200 { - println!( - "Error response: status={}, body={}", - input_items_response.status_code(), - input_items_response.text() - ); - } - assert_eq!(input_items_response.status_code(), 200); - let input_items: api::models::ResponseInputItemList = input_items_response.json(); - println!("Input items: {input_items:?}"); - - // Should have at least one input item - assert!(!input_items.data.is_empty()); - - // 8. Test streaming response with file - let stream_response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": model_id, - "conversation": conversation.id, - "input": [{ - "role": "user", - "content": [{ - "type": "input_text", - "text": "What does the file say?" - }] - }], - "max_output_tokens": 50, - "stream": true - })) - .await; - assert_eq!(stream_response.status_code(), 200); - let stream_text = stream_response.text(); - println!( - "Stream response (first 500 chars): {}", - &stream_text[..stream_text.len().min(500)] + let openapi = response.json::(); + let paths = openapi["paths"] + .as_object() + .expect("OpenAPI paths must be an object"); + assert!( + paths.keys().all(|path| !path.starts_with("/v1/files")), + "OpenAPI must not expose retired Files API paths" ); - // Verify we got SSE events - assert!(stream_text.contains("event:")); - assert!(stream_text.contains("data:")); - - // Parse the streaming response to check for completion - let mut final_response: Option = None; - for line_chunk in stream_text.split("\n\n") { - if line_chunk.trim().is_empty() { - continue; - } - - let mut event_type = ""; - let mut event_data = ""; - - for line in line_chunk.lines() { - if let Some(event_name) = line.strip_prefix("event: ") { - event_type = event_name; - } else if let Some(data) = line.strip_prefix("data: ") { - event_data = data; - } - } - - if event_type == "response.completed" && !event_data.is_empty() { - if let Ok(event_json) = serde_json::from_str::(event_data) { - if let Some(response_obj) = event_json.get("response") { - final_response = - serde_json::from_value::(response_obj.clone()) - .ok(); - } - } - break; - } - } - + let tags = openapi["tags"] + .as_array() + .expect("OpenAPI tags must be an array"); assert!( - final_response.is_some(), - "Expected final response in stream" - ); - let final_resp = final_response.unwrap(); - // Extract text from the response output - let mut final_text = String::new(); - for item in &final_resp.output { - if let api::models::ResponseOutputItem::Message { content, .. } = item { - for part in content { - if let api::models::ResponseOutputContent::OutputText { text, .. } = part { - final_text.push_str(text); - } - } - } - } - let final_text = final_text.trim(); - // Verify we got the expected response from the mock - assert_eq!( - expected_response, final_text, - "final response does not match expected mock response" + tags.iter().all(|tag| tag["name"] != "Files"), + "OpenAPI must not expose the Files tag" ); - assert_eq!(final_resp.status, api::models::ResponseStatus::Completed); - // If usage is present, ensure input_tokens_details.cached_tokens is non-negative - if let Some(details) = final_resp.usage.input_tokens_details { + let schemas = openapi["components"]["schemas"] + .as_object() + .expect("OpenAPI schemas must be an object"); + for schema in [ + "FileUploadResponse", + "FileListResponse", + "FileDeleteResponse", + "ExpiresAfter", + ] { assert!( - details.cached_tokens >= 0, - "cached_tokens in input_tokens_details should be non-negative" + !schemas.contains_key(schema), + "OpenAPI must not expose the retired {schema} schema" ); } } - -#[tokio::test] -async fn test_file_not_found_in_response_api() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(conversation_response.status_code(), 201); - let conversation: api::models::ConversationObject = conversation_response.json(); - - // Try to create a response with a non-existent file - let fake_file_id = "file-00000000-0000-0000-0000-000000000000"; - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": model_id, - "conversation": conversation.id, - "input": [{ - "role": "user", - "content": [{ - "type": "input_text", - "text": "What's in the file?" - }, { - "type": "input_file", - "file_id": fake_file_id - }] - }], - "max_output_tokens": 50, - "stream": true - })) - .await; - - // The response should start streaming, but will fail when trying to fetch the file - assert_eq!(response.status_code(), 200); - let stream_text = response.text(); - - // Check for error event in the stream - assert!( - stream_text.contains("response.failed") || stream_text.contains("error"), - "Expected error in stream response" - ); -} - -#[tokio::test] -async fn test_multiple_files_in_response_api() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // 1. Upload multiple text files - let file1_content = b"File 1: Product specifications\nPrice: $100\nColor: Red"; - let upload1 = upload_file( - &server, - &api_key, - "product1.txt", - file1_content, - "text/plain", - "user_data", - ) - .await; - assert_eq!(upload1.status_code(), 201); - let file1: api::models::FileUploadResponse = upload1.json(); - - let file2_content = b"File 2: Product specifications\nPrice: $200\nColor: Blue"; - let upload2 = upload_file( - &server, - &api_key, - "product2.txt", - file2_content, - "text/plain", - "user_data", - ) - .await; - assert_eq!(upload2.status_code(), 201); - let file2: api::models::FileUploadResponse = upload2.json(); - - // 2. Create a conversation - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(conversation_response.status_code(), 201); - let conversation: api::models::ConversationObject = conversation_response.json(); - - // 4. Create a response with multiple files - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": model_id, - "conversation": conversation.id, - "input": [{ - "role": "user", - "content": [{ - "type": "input_text", - "text": "Compare these two products:" - }, { - "type": "input_file", - "file_id": file1.id - }, { - "type": "input_file", - "file_id": file2.id - }] - }], - "max_output_tokens": 100, - "stream": false - })) - .await; - - assert_eq!(response.status_code(), 200); - let response_obj: api::models::ResponseObject = response.json(); - - // Verify the response completed successfully - assert_eq!(response_obj.status, api::models::ResponseStatus::Completed); - - // Verify the response has output - assert!(!response_obj.output.is_empty()); - - println!("Successfully processed multiple files in response"); -} diff --git a/crates/api/tests/e2e_all/vpc_login.rs b/crates/api/tests/e2e_all/vpc_login.rs index 167b792d3..0612cc514 100644 --- a/crates/api/tests/e2e_all/vpc_login.rs +++ b/crates/api/tests/e2e_all/vpc_login.rs @@ -284,7 +284,7 @@ async fn test_vpc_login_api_key_works() { let body = response.json::(); - // Try to use the API key on an authenticated endpoint + // A valid API key reaches the retired Files endpoint. let auth_response = server .get("/v1/files?limit=1") .add_header("Authorization", format!("Bearer {}", body.api_key)) @@ -293,8 +293,8 @@ async fn test_vpc_login_api_key_works() { assert_eq!( auth_response.status_code(), - 200, - "API key from VPC login should work for authenticated requests" + 410, + "API key from VPC login should reach the Files API retirement response" ); println!("✅ API key from VPC login works correctly"); From 0919059c254cc171a051584d9d4f56e36ed86579 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:34:25 +0800 Subject: [PATCH 02/31] Deprecate Conversations API routes --- README.md | 8 +- crates/api/src/lib.rs | 185 +- crates/api/src/openapi.rs | 16 - crates/api/src/routes/conversations.rs | 21 + crates/api/tests/e2e_all/conversations.rs | 3927 +-------------------- docs/local-development.md | 3 +- 6 files changed, 184 insertions(+), 3976 deletions(-) diff --git a/README.md b/README.md index 14411eed9..c10ae2c6f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NEAR AI Cloud API -A Rust-based cloud API for AI model inference, conversation management, and organization administration. Part of the NEAR AI platform alongside the Chat API. +A Rust-based cloud API for AI model inference and organization administration. Part of the NEAR AI platform alongside the Chat API. ## Quick Start @@ -171,6 +171,12 @@ Once all checks pass, you're ready to commit! ## API Documentation +### Retired Conversations API + +The `/v1/conversations` API is retired and every request to that path returns +`410 Gone`. Use stateless `POST /v1/responses` requests with `store: false` and +send any conversation history needed for inference in each request. + Interactive API documentation is available when running the server: - **Scalar UI**: `http://localhost:3000/docs` - Modern, beautiful API documentation with interactive playground diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 895d2dcec..e94d937b8 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -43,7 +43,7 @@ use axum::{ extract::DefaultBodyLimit, middleware::{from_fn, from_fn_with_state, map_response, Next}, response::Html, - routing::{get, post}, + routing::{any, get, post}, Router, }; use config::ApiConfig; @@ -1256,10 +1256,7 @@ pub fn build_app_with_config( rate_limit_state.clone(), ); - let conversation_routes = build_conversation_routes( - domain_services.conversation_service, - &auth_components.auth_state_middleware, - ); + let conversation_routes = build_conversation_routes(&auth_components.auth_state_middleware); let management_routes = build_management_router( app_state.clone(), @@ -1752,57 +1749,26 @@ pub fn build_mcp_routes( )) } -/// Build conversation routes with auth -pub fn build_conversation_routes( - conversation_service: Arc, - auth_state_middleware: &AuthState, -) -> Router { +/// Build retired Conversation API routes. +/// +/// Conversation data remains in place for now, but the public API no longer +/// permits reading or writing it. Keep the route catch-all so callers receive +/// a stable migration response rather than a generic 404 or 405. +pub fn build_conversation_routes(auth_state_middleware: &AuthState) -> Router { + build_retired_conversation_routes().layer(from_fn_with_state( + auth_state_middleware.clone(), + auth_middleware_with_api_key, + )) +} + +fn build_retired_conversation_routes() -> Router { Router::new() - .route("/conversations", post(conversations::create_conversation)) - .route( - "/conversations/batch", - post(conversations::batch_get_conversations), - ) - .route( - "/conversations/{conversation_id}", - get(conversations::get_conversation), - ) - .route( - "/conversations/{conversation_id}", - post(conversations::update_conversation), - ) - .route( - "/conversations/{conversation_id}", - axum::routing::delete(conversations::delete_conversation), - ) - .route( - "/conversations/{conversation_id}/pin", - post(conversations::pin_conversation).delete(conversations::unpin_conversation), - ) - .route( - "/conversations/{conversation_id}/archive", - post(conversations::archive_conversation).delete(conversations::unarchive_conversation), - ) + .route("/conversations", any(conversations::conversation_api_gone)) + .route("/conversations/", any(conversations::conversation_api_gone)) .route( - "/conversations/{conversation_id}/clone", - post(conversations::clone_conversation), + "/conversations/{*path}", + any(conversations::conversation_api_gone), ) - .route( - "/conversations/{conversation_id}/items", - get(conversations::list_conversation_items), - ) - .route( - "/conversations/{conversation_id}/items", - post(conversations::create_conversation_items), - ) - .with_state( - conversation_service - as Arc, - ) - .layer(from_fn_with_state( - auth_state_middleware.clone(), - auth_middleware_with_api_key, - )) } /// Build attestation routes with auth. @@ -2635,27 +2601,33 @@ mod tests { } #[test] - fn test_openapi_conversation_action_paths_use_v1_prefix() { + fn test_openapi_excludes_retired_conversation_api() { let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); let paths = spec["paths"].as_object().unwrap(); + assert!( + paths + .keys() + .all(|path| !path.starts_with("/v1/conversations")), + "OpenAPI must not advertise retired Conversation routes" + ); - // Pin/unpin and archive/unarchive share path keys with different methods. - for path in [ - "/v1/conversations/{conversation_id}/archive", - "/v1/conversations/{conversation_id}/clone", - "/v1/conversations/{conversation_id}/pin", - ] { - assert!(paths.contains_key(path), "missing OpenAPI path: {path}"); - } + let tags = spec["tags"].as_array().unwrap(); + assert!( + tags.iter().all(|tag| tag["name"] != "Conversations"), + "OpenAPI must not advertise the Conversations tag" + ); - for path in [ - "/conversations/{conversation_id}/archive", - "/conversations/{conversation_id}/clone", - "/conversations/{conversation_id}/pin", + let schemas = spec["components"]["schemas"].as_object().unwrap(); + for schema in [ + "CreateConversationRequest", + "ConversationObject", + "UpdateConversationRequest", + "ConversationDeleteResult", + "ConversationItemList", ] { assert!( - !paths.contains_key(path), - "OpenAPI path is missing /v1 prefix: {path}" + !schemas.contains_key(schema), + "OpenAPI must not expose retired Conversation schema {schema}" ); } } @@ -2696,6 +2668,83 @@ mod tests { assert!(!properties.contains_key("resultJson")); } + #[tokio::test] + async fn conversation_routes_return_gone_for_every_retired_surface() { + let app = Router::new().nest("/v1", build_retired_conversation_routes()); + let routes = [ + (axum::http::Method::POST, "/v1/conversations"), + (axum::http::Method::GET, "/v1/conversations/"), + (axum::http::Method::POST, "/v1/conversations/batch"), + (axum::http::Method::GET, "/v1/conversations/conv_example"), + (axum::http::Method::POST, "/v1/conversations/conv_example"), + (axum::http::Method::DELETE, "/v1/conversations/conv_example"), + ( + axum::http::Method::POST, + "/v1/conversations/conv_example/pin", + ), + ( + axum::http::Method::DELETE, + "/v1/conversations/conv_example/pin", + ), + ( + axum::http::Method::POST, + "/v1/conversations/conv_example/archive", + ), + ( + axum::http::Method::DELETE, + "/v1/conversations/conv_example/archive", + ), + ( + axum::http::Method::POST, + "/v1/conversations/conv_example/clone", + ), + ( + axum::http::Method::GET, + "/v1/conversations/conv_example/items", + ), + ( + axum::http::Method::POST, + "/v1/conversations/conv_example/items", + ), + // The catch-all also keeps unknown legacy subpaths from becoming + // misleading 404 or 405 responses. + ( + axum::http::Method::PATCH, + "/v1/conversations/conv_example/unknown", + ), + ]; + + for (method, path) in routes { + let response = app + .clone() + .oneshot( + HttpRequest::builder() + .method(method.clone()) + .uri(path) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::GONE, + "{method} {path} must return 410 Gone" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let error: crate::models::ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(error.error.r#type, "gone"); + assert_eq!( + error.error.code.as_deref(), + Some("conversation_api_retired") + ); + assert!(error.error.message.contains("POST /v1/responses")); + } + } /// Example of how to set up the application for E2E testing #[tokio::test] #[ignore] // Remove ignore to run with a real database and Patroni cluster diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index fd2f54c19..5a0d740ff 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -25,7 +25,6 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Conversations", description = "Conversation management"), (name = "Responses", description = "Response handling and streaming"), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), @@ -57,18 +56,6 @@ use utoipa::{Modify, OpenApi}; // Model endpoints (public model catalog) crate::routes::models::list_models, crate::routes::models::get_model_by_name, - // Conversation endpoints - crate::routes::conversations::create_conversation, - crate::routes::conversations::get_conversation, - crate::routes::conversations::update_conversation, - crate::routes::conversations::delete_conversation, - crate::routes::conversations::pin_conversation, - crate::routes::conversations::unpin_conversation, - crate::routes::conversations::archive_conversation, - crate::routes::conversations::unarchive_conversation, - crate::routes::conversations::clone_conversation, - crate::routes::conversations::list_conversation_items, - crate::routes::conversations::create_conversation_items, // Response endpoints crate::routes::responses::create_response, crate::routes::responses::get_response, @@ -243,9 +230,6 @@ use utoipa::{Modify, OpenApi}; AdminUserResponse, crate::routes::users::UpdateUserProfileRequest, crate::routes::users::UserStatusResponse, - // Conversation models - CreateConversationRequest, ConversationObject, - UpdateConversationRequest, ConversationDeleteResult, ConversationItemList, // Response models CreateResponseRequest, ResponseObject, // Attestation models diff --git a/crates/api/src/routes/conversations.rs b/crates/api/src/routes/conversations.rs index 01043048c..31ff254f6 100644 --- a/crates/api/src/routes/conversations.rs +++ b/crates/api/src/routes/conversations.rs @@ -14,6 +14,27 @@ use std::sync::Arc; use tracing::debug; use uuid::Uuid; +const CONVERSATIONS_API_RETIRED_MESSAGE: &str = "The Conversations API has been deprecated and is no longer available. Use stateless POST /v1/responses with store: false and include any prior conversation history in the request."; + +/// Return a stable migration response for every retired Conversation API route. +/// +/// API-key authentication is enforced by the router. This handler intentionally +/// has no service dependencies, so retired requests cannot read or write +/// existing conversation data. +pub async fn conversation_api_gone() -> (StatusCode, ResponseJson) { + ( + StatusCode::GONE, + ResponseJson(ErrorResponse { + error: ErrorDetail { + message: CONVERSATIONS_API_RETIRED_MESSAGE.to_string(), + r#type: "gone".to_string(), + param: None, + code: Some("conversation_api_retired".to_string()), + }, + }), + ) +} + // Helper functions for ID conversion fn parse_conversation_id(id_str: &str) -> Result { // Handle both prefixed (conv_*) and raw UUID formats diff --git a/crates/api/tests/e2e_all/conversations.rs b/crates/api/tests/e2e_all/conversations.rs index e079e42f0..1a9145d26 100644 --- a/crates/api/tests/e2e_all/conversations.rs +++ b/crates/api/tests/e2e_all/conversations.rs @@ -1,3906 +1,55 @@ -// Import common test utilities - -use crate::common::*; - -use api::models::{ - ConversationContentPart, ConversationItem, ResponseOutputContent, ResponseOutputItem, -}; - -// Helper functions for conversation and response tests -async fn create_conversation( - server: &axum_test::TestServer, - api_key: String, -) -> api::models::ConversationObject { - let response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation" - })) - .await; - assert_eq!(response.status_code(), 201); - response.json::() -} - -#[allow(dead_code)] -async fn get_conversation( - server: &axum_test::TestServer, - conversation_id: String, - api_key: String, -) -> api::models::ConversationObject { - let response = server - .get(format!("/v1/conversations/{conversation_id}").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response.status_code(), 200); - response.json::() -} - -async fn list_conversation_items( - server: &axum_test::TestServer, - conversation_id: String, - api_key: String, -) -> api::models::ConversationItemList { - let response = server - .get(format!("/v1/conversations/{conversation_id}/items").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response.status_code(), 200); - response.json::() -} - -async fn create_response( - server: &axum_test::TestServer, - conversation_id: String, - model: String, - message: String, - max_tokens: i64, - api_key: String, -) -> api::models::ResponseObject { - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation_id, - }, - "input": message, - "temperature": 0.7, - "max_output_tokens": max_tokens, - "stream": false, - "model": model - })) - .await; - assert_eq!(response.status_code(), 200); - response.json::() -} - -async fn create_response_stream( - server: &axum_test::TestServer, - conversation_id: String, - model: String, - message: String, - max_tokens: i64, - api_key: String, -) -> (String, api::models::ResponseObject) { - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation_id, - }, - "input": message, - "temperature": 0.7, - "max_output_tokens": max_tokens, - "stream": true, - "model": model - })) - .await; - - assert_eq!(response.status_code(), 200); - - // For streaming responses, we get SSE events as text - let response_text = response.text(); - - let mut content = String::new(); - let mut final_response: Option = None; - - // Parse SSE format: "event: \ndata: \n\n" - for line_chunk in response_text.split("\n\n") { - if line_chunk.trim().is_empty() { - continue; - } - - let mut event_type = ""; - let mut event_data = ""; - - for line in line_chunk.lines() { - if let Some(event_name) = line.strip_prefix("event: ") { - event_type = event_name; - } else if let Some(data) = line.strip_prefix("data: ") { - event_data = data; - } - } - - if !event_data.is_empty() { - if let Ok(event_json) = serde_json::from_str::(event_data) { - match event_type { - "response.output_text.delta" => { - // Accumulate content deltas as they arrive - if let Some(delta) = event_json.get("delta").and_then(|v| v.as_str()) { - content.push_str(delta); - println!("Delta: {delta}"); - } - } - "response.completed" => { - // Extract final response from completed event - if let Some(response_obj) = event_json.get("response") { - final_response = Some( - serde_json::from_value::( - response_obj.clone(), - ) - .expect("Failed to parse response.completed event"), - ); - println!("Stream completed"); - } - } - "response.created" => { - println!("Response created"); - } - "response.in_progress" => { - println!("Response in progress"); - } - _ => { - println!("Event: {event_type}"); - } - } - } - } - } - - let final_resp = - final_response.expect("Expected to receive response.completed event from stream"); - (content, final_resp) -} - -// ============================================ -// Response Tests -// ============================================ - -/// When inference fails at the start (e.g. model not found), the stream emits response.failed -/// and the response has status=Failed with one failed output item (content empty). -#[tokio::test] -async fn test_response_stream_fails_with_failed_event_when_inference_fails_at_start() { - let (server, _pool, _mock, database) = setup_test_server_with_pool().await; - // Do NOT call setup_qwen_model so that the requested model is not in the DB - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - let conversation = create_conversation(&server, api_key.clone()).await; - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { "id": conversation.id }, - "input": "hello", - "temperature": 0.7, - "max_output_tokens": 10, - "stream": true, - "model": "non-existent-model-for-failed-test" - })) - .await; - - assert_eq!( - response.status_code(), - 200, - "POST /v1/responses should return 200" - ); - let response_text = response.text(); - - let mut saw_created = false; - let mut saw_in_progress = false; - let mut saw_failed = false; - let mut saw_completed = false; - let mut failed_text: Option = None; - - for line_chunk in response_text.split("\n\n") { - if line_chunk.trim().is_empty() { - continue; - } - let mut event_type = ""; - let mut event_data = ""; - for line in line_chunk.lines() { - if let Some(name) = line.strip_prefix("event: ") { - event_type = name; - } else if let Some(data) = line.strip_prefix("data: ") { - event_data = data; - } - } - if event_type == "response.created" { - saw_created = true; - } else if event_type == "response.in_progress" { - saw_in_progress = true; - } else if event_type == "response.failed" { - saw_failed = true; - if let Ok(json) = serde_json::from_str::(event_data) { - failed_text = json.get("text").and_then(|v| v.as_str()).map(String::from); - } - } else if event_type == "response.completed" { - saw_completed = true; - } - } - - assert!(saw_created, "Stream should contain response.created"); - assert!( - saw_in_progress, - "Stream should contain response.in_progress" - ); - assert!( - saw_failed, - "Stream should contain response.failed when inference fails at start" - ); - assert!( - !saw_completed, - "Stream should NOT contain response.completed when inference fails at start" - ); - assert!( - failed_text.as_deref().is_some_and(|s| !s.is_empty()), - "response.failed event should have non-empty text (error message)" - ); - - // Verify DB: latest response for this conversation has status=Failed and one assistant item with status=failed, content containing error message - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - let pool = database.pool(); - let client = pool.get().await.expect("db connection"); - let conv_uuid = uuid::Uuid::parse_str( - conversation - .id - .strip_prefix("conv_") - .unwrap_or(&conversation.id), - ) - .expect("conv id"); - let resp_row = client - .query_one( - "SELECT id, status FROM responses WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT 1", - &[&conv_uuid], - ) - .await - .expect("query responses"); - let status: String = resp_row.get("status"); - assert_eq!(status, "failed", "Response status in DB should be failed"); - - let item_rows = client - .query( - "SELECT item FROM response_items WHERE conversation_id = $1 ORDER BY created_at ASC", - &[&conv_uuid], - ) - .await - .expect("query response_items"); - let assistant_items: Vec = item_rows - .into_iter() - .filter_map(|row| { - let item: serde_json::Value = row.get("item"); - if item.get("role").and_then(|v| v.as_str()) == Some("assistant") { - Some(item) - } else { - None - } - }) - .collect(); - assert_eq!( - assistant_items.len(), - 1, - "Should have exactly one assistant output item (the failed one)" - ); - let item = &assistant_items[0]; - assert_eq!(item.get("status").and_then(|v| v.as_str()), Some("failed")); - let content = item - .get("content") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - - // Verify that the failed item contains the error message in its content - assert!( - !content.is_empty(), - "Failed item content should contain the error message" - ); - assert_eq!( - content.len(), - 1, - "Failed item should have exactly one content item" - ); - - let content_item = &content[0]; - assert_eq!( - content_item.get("type").and_then(|v| v.as_str()), - Some("output_text"), - "Content item should be output_text" - ); - - let error_text_in_content = content_item - .get("text") - .and_then(|v| v.as_str()) - .map(String::from); - - assert!( - error_text_in_content - .as_deref() - .is_some_and(|s| !s.is_empty()), - "Failed item content should have non-empty error message text" - ); - - // Verify that the error message in content matches the error message from response.failed event - if let (Some(stream_error), Some(content_error)) = - (failed_text.as_ref(), error_text_in_content.as_ref()) - { - assert_eq!( - stream_error, content_error, - "Error message in response.failed event should match error message in failed item content" - ); - } -} - -#[tokio::test] -async fn test_responses_api() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Conversation: {conversation:?}"); - - let message = "Hello, how are you?".to_string(); - let max_tokens = 10; - let response = create_response( - &server, - conversation.id.clone(), - model_id.clone(), - message.clone(), - max_tokens, - api_key.clone(), - ) - .await; - println!("Response: {response:?}"); - - // Check that response completed successfully - assert_eq!(response.status, api::models::ResponseStatus::Completed); - - // Check that we got usage information (tokens were generated) - assert!( - response.usage.output_tokens > 0, - "Expected output tokens to be generated" - ); - - // Check that we have output content structure (even if text is empty due to VLLM issues) - assert!(!response.output.is_empty(), "Expected output items"); - - // Log the text we got (may be empty if VLLM has issues) - for output_item in &response.output { - if let ResponseOutputItem::Message { content, .. } = output_item { - for content_part in content { - if let ResponseOutputContent::OutputText { text, .. } = content_part { - println!( - "Response text length: {} chars, content: '{}'", - text.len(), - text - ); - if text.is_empty() { - println!( - "Warning: VLLM returned empty text despite reporting {} output tokens", - response.usage.output_tokens - ); - } - } - } - } - } - - let conversation_items = - list_conversation_items(&server, conversation.id, api_key.clone()).await; - assert_eq!(conversation_items.data.len(), 2); - match &conversation_items.data[0] { - ConversationItem::Message { content, .. } => { - if let ConversationContentPart::InputText { text } = &content[0] { - assert_eq!(text, message.as_str()); - } - } - _ => panic!("Expected Message item type"), - } -} - -#[tokio::test] -async fn test_streaming_responses_api() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Conversation: {conversation:?}"); - - // Test streaming response - let message = "Hello, how are you?".to_string(); - let (streamed_content, streaming_response) = create_response_stream( - &server, - conversation.id.clone(), - model_id.clone(), - message.clone(), - 50, - api_key.clone(), - ) - .await; - - println!("Streamed Content: {streamed_content}"); - println!("Final Response: {streaming_response:?}"); - - // Verify we got content from the stream - assert!( - !streamed_content.is_empty(), - "Expected non-empty streamed content" - ); - - // Verify the final response has content - assert!(streaming_response.output.iter().any(|item| { - if let ResponseOutputItem::Message { content, .. } = item { - content.iter().any(|part| { - if let ResponseOutputContent::OutputText { text, .. } = part { - !text.is_empty() - } else { - false - } - }) - } else { - false - } - })); -} - -// ============================================ -// Usage Limit Enforcement Tests -// ============================================ - -#[tokio::test] -async fn test_responses_api_usage_limit_enforcement() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 1).await; // 1 nano-dollar (minimal) - println!("Created organization: {org:?}"); - let api_key = get_api_key_for_org(&server, org.id).await; - let model_name = setup_qwen_model(&server).await; - - // Create a conversation for the response - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation" - })) - .await; - - assert_eq!( - conversation_response.status_code(), - 201, - "Failed to create conversation" - ); - let conversation = conversation_response.json::(); - - // First request should succeed (no usage yet) - let response1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Hi", - "model": model_name, - "stream": false, - "max_output_tokens": 10 - })) - .await; - - println!("First request status: {}", response1.status_code()); - // This might succeed or fail depending on timing - - // Wait for usage to be recorded - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // Second request should fail with payment required - let response2 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Hi again", - "model": model_name, - "stream": false, - "max_output_tokens": 10 - })) - .await; - - println!("Second request status: {}", response2.status_code()); - println!("Second request body: {}", response2.text()); - - // Should get 402 Payment Required after exceeding limit - assert!( - response2.status_code() == 402, - "Expected 402 Payment Required, got: {}", - response2.status_code() - ); - - // Since we got 402, verify the error message - let error_response = response2.json::(); - assert!( - error_response - .error - .message - .contains("Credit limit exceeded."), - "Error response should indicate no credits, got: {}", - error_response.error.message - ); -} - -#[tokio::test] -async fn test_responses_api_no_credits() { - let server = setup_test_server().await; - // Create org without credits (no limit set) - let org = create_org(&server).await; - let api_key = get_api_key_for_org(&server, org.id).await; - let model_name = setup_qwen_model(&server).await; - - // Create a conversation for the response - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation" - })) - .await; - - assert_eq!( - conversation_response.status_code(), - 201, - "Failed to create conversation" - ); - let conversation = conversation_response.json::(); - - // Request should fail with payment required (no credits) - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Hi", - "model": model_name, - "stream": false, - "max_output_tokens": 10 - })) - .await; - - println!("Response status: {}", response.status_code()); - println!("Response body: {}", response.text()); - - assert_eq!( - response.status_code(), - 402, - "Expected 402 Payment Required when no credits are available" - ); - - let error_response = response.json::(); - assert!( - error_response - .error - .message - .contains("No spending limit configured"), - "Error response should indicate no credits, got: {}", - error_response.error.message - ); -} - -#[tokio::test] -async fn test_responses_api_zero_credits() { - let server = setup_test_server().await; - // Create org with zero credits - let org = setup_org_with_credits(&server, 0).await; - let api_key = get_api_key_for_org(&server, org.id).await; - let model_name = setup_qwen_model(&server).await; - - // Create a conversation for the response - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation" - })) - .await; - - assert_eq!( - conversation_response.status_code(), - 201, - "Failed to create conversation" - ); - let conversation = conversation_response.json::(); - - // Request should fail with payment required (zero credits) - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Hi", - "model": model_name, - "stream": false, - "max_output_tokens": 10 - })) - .await; - - println!("Response status: {}", response.status_code()); - println!("Response body: {}", response.text()); - - assert_eq!( - response.status_code(), - 402, - "Expected 402 Payment Required when credits are zero" - ); - - let error_response = response.json::(); - assert!( - error_response - .error - .message - .contains("Credit limit exceeded."), - "Error response should indicate no credits, got: {}", - error_response.error.message - ); -} - -#[tokio::test] -async fn test_responses_api_sufficient_credits() { - let server = setup_test_server().await; - // Create org with sufficient credits - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - let model_name = setup_qwen_model(&server).await; - - // Create a conversation for the response - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation" - })) - .await; - - assert_eq!( - conversation_response.status_code(), - 201, - "Failed to create conversation" - ); - let conversation = conversation_response.json::(); - - // Request should succeed with sufficient credits - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Say hello in exactly 5 words.", - "model": model_name, - "stream": false, - "max_output_tokens": 50 - })) - .await; - - println!("Response status: {}", response.status_code()); - assert_eq!( - response.status_code(), - 200, - "Expected 200 OK when sufficient credits are available" - ); - - let response_obj = response.json::(); - assert_eq!( - response_obj.status, - api::models::ResponseStatus::Completed, - "Response should complete successfully" - ); -} - -#[tokio::test] -async fn test_responses_api_streaming_usage_limit() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 1).await; // 1 nano-dollar (minimal) - println!("Created organization: {org:?}"); - let api_key = get_api_key_for_org(&server, org.id).await; - let model_name = setup_qwen_model(&server).await; - - // Create a conversation for the response - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation" - })) - .await; - - assert_eq!( - conversation_response.status_code(), - 201, - "Failed to create conversation" - ); - let conversation = conversation_response.json::(); - - // First streaming request should succeed (no usage yet) - let response1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Hi", - "model": model_name, - "stream": true, - "max_output_tokens": 10 - })) - .await; - - println!( - "First streaming request status: {}", - response1.status_code() - ); - // This might succeed or fail depending on timing - - // Wait for usage to be recorded - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // Second streaming request should fail with payment required - let response2 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Hi again", - "model": model_name, - "stream": true, - "max_output_tokens": 10 - })) - .await; - - println!( - "Second streaming request status: {}", - response2.status_code() - ); - println!("Second streaming request body: {}", response2.text()); - - // Should get 402 Payment Required after exceeding limit - assert!( - response2.status_code() == 402, - "Expected 402 Payment Required, got: {}", - response2.status_code() - ); - - // If we got 402, verify the error message - let error_response = response2.json::(); - assert!( - error_response - .error - .message - .contains("Credit limit exceeded."), - "Error response should indicate no credits, got: {}", - error_response.error.message - ); -} - -// ============================================ -// Conversation Tests -// ============================================ - -#[tokio::test] -async fn test_conversations_api() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Test creating a conversation - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation" - })) - .await; - assert_eq!(create_response.status_code(), 201); -} - -#[tokio::test] -async fn test_create_conversation_items_backfill() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test 1: Create a single item with simple text content - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [ - { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": "Hello!"} - ] - } - ] - })) - .await; - - assert_eq!(create_items_response.status_code(), 200); - let items_response = create_items_response.json::(); - assert_eq!(items_response.data.len(), 1); - assert_eq!(items_response.object, "list"); - assert!(!items_response.first_id.is_empty()); - assert!(!items_response.last_id.is_empty()); - assert!(!items_response.has_more); - - // Verify the item content - match &items_response.data[0] { - ConversationItem::Message { - role, - content, - status, - .. - } => { - assert_eq!(role, "user"); - assert!(matches!(status, api::models::ResponseItemStatus::Completed)); - assert_eq!(content.len(), 1); - match &content[0] { - ConversationContentPart::InputText { text } => { - assert_eq!(text, "Hello!"); - } - _ => panic!("Expected InputText content part"), - } - } - _ => panic!("Expected Message item type"), - } - - // Test 2: Verify items can be retrieved via list endpoint - let list_response = - list_conversation_items(&server, conversation.id.clone(), api_key.clone()).await; - assert!( - !list_response.data.is_empty(), - "Should have at least the backfilled item" - ); - - // Find our backfilled item - let backfilled_item = list_response.data.iter().find(|item| match item { - ConversationItem::Message { content, .. } => content.iter().any( - |part| matches!(part, ConversationContentPart::InputText { text } if text == "Hello!"), - ), - _ => false, - }); - assert!( - backfilled_item.is_some(), - "Backfilled item should be retrievable" - ); - - println!("✅ Basic backfill test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_multiple() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test: Create multiple items (up to 20) - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [ - { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": "First message"} - ] - }, - { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": "Second message"} - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - {"type": "input_text", "text": "Assistant response"} - ] - } - ] - })) - .await; - - assert_eq!(create_items_response.status_code(), 200); - let items_response = create_items_response.json::(); - assert_eq!(items_response.data.len(), 3); - assert_eq!(items_response.first_id, items_response.data[0].id()); - assert_eq!(items_response.last_id, items_response.data[2].id()); - - // Verify all items were created correctly - assert_eq!(items_response.data[0].role(), "user"); - assert_eq!(items_response.data[1].role(), "user"); - assert_eq!(items_response.data[2].role(), "assistant"); - - println!("✅ Multiple items backfill test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_validation_empty() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test: Empty items array should fail - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [] - })) - .await; - - assert_eq!(create_items_response.status_code(), 400); - let error_response = create_items_response.json::(); - assert!( - error_response.error.message.contains("empty") - || error_response.error.message.contains("Items") - ); - - println!("✅ Empty items validation test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_validation_too_many() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test: More than 20 items should fail - let items: Vec = (0..21) - .map(|i| { - serde_json::json!({ - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": format!("Message {}", i)} - ] - }) - }) - .collect(); - - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": items - })) - .await; - - assert_eq!(create_items_response.status_code(), 400); - let error_response = create_items_response.json::(); - assert!( - error_response.error.message.contains("20") - || error_response.error.message.contains("more than") - ); - - println!("✅ Too many items validation test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_nonexistent_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Test: Non-existent conversation should fail - let fake_conv_id = "conv_00000000-0000-0000-0000-000000000000"; - let create_items_response = server - .post(format!("/v1/conversations/{fake_conv_id}/items").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [ - { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": "Hello!"} - ] - } - ] - })) - .await; - - // Unknown conversations return the same non-enumerating 404 as - // conversations owned by another workspace (see issue nearai/infra#190). - assert_eq!(create_items_response.status_code(), 404); - let error_response = create_items_response.json::(); - assert_eq!(error_response.error.message, "Conversation not found"); - - println!("✅ Non-existent conversation validation test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_max_limit() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test: Exactly 20 items should succeed (max limit) - let items: Vec = (0..20) - .map(|i| { - serde_json::json!({ - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": format!("Message {}", i)} - ] - }) - }) - .collect(); - - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": items - })) - .await; - - assert_eq!(create_items_response.status_code(), 200); - let items_response = create_items_response.json::(); - assert_eq!(items_response.data.len(), 20); - - println!("✅ Max limit (20 items) test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_text_content() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test: Content as simple text string (not array) - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [ - { - "type": "message", - "role": "user", - "content": "Simple text content" - } - ] - })) - .await; - - assert_eq!(create_items_response.status_code(), 200); - let items_response = create_items_response.json::(); - assert_eq!(items_response.data.len(), 1); - - match &items_response.data[0] { - ConversationItem::Message { content, .. } => { - assert_eq!(content.len(), 1); - match &content[0] { - ConversationContentPart::InputText { text } => { - assert_eq!(text, "Simple text content"); - } - _ => panic!("Expected InputText content part"), - } - } - _ => panic!("Expected Message item type"), - } - - println!("✅ Text content format test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_with_file_content() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test: Create item with file content - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [ - { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": "Could you read the file?"}, - {"type": "input_file", "file_id": "32af7670-f5b9-47a0-a952-20d5d3831e67"}, - {"type": "input_text", "text": "Discussion about [file-32af7670f5b947a0a95220d5d3831e67] in text"} - ] - } - ] - })) - .await; - - assert_eq!(create_items_response.status_code(), 200); - let items_response = create_items_response.json::(); - assert_eq!(items_response.data.len(), 1); - - // Verify the item content - this is the key test - match &items_response.data[0] { - ConversationItem::Message { role, content, .. } => { - assert_eq!(role, "user"); - assert_eq!(content.len(), 3); - - // First content part should be text - match &content[0] { - ConversationContentPart::InputText { text } => { - assert_eq!(text, "Could you read the file?"); - } - _ => panic!("Expected InputText content part"), - } - - // Second content part should be file (valid UUID) - match &content[1] { - ConversationContentPart::InputFile { file_id, detail } => { - assert_eq!(file_id, "32af7670-f5b9-47a0-a952-20d5d3831e67"); - assert_eq!(detail, &None); - } - _ => panic!("Expected InputFile content part but got: {:?}", &content[1]), - } - - // Third content part should be text (invalid UUID format) - match &content[2] { - ConversationContentPart::InputText { text } => { - assert_eq!( - text, - "Discussion about [file-32af7670f5b947a0a95220d5d3831e67] in text" - ); - } - _ => panic!( - "Expected InputText for invalid file ID but got: {:?}", - &content[2] - ), - } - } - _ => panic!("Expected Message item type"), - } - - // Verify items can be retrieved via list endpoint - let list_response = - list_conversation_items(&server, conversation.id.clone(), api_key.clone()).await; - - // Find our backfilled item with file content - let file_item = list_response.data.iter().find(|item| match item { - ConversationItem::Message { content, .. } => content - .iter() - .any(|part| matches!(part, ConversationContentPart::InputFile { .. })), - _ => false, - }); - assert!( - file_item.is_some(), - "Backfilled item with file should be retrievable" - ); - - println!("✅ File content parsing test passed"); -} - -#[tokio::test] -async fn test_create_conversation_items_different_roles() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Test: Different roles (user, assistant, system) - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [ - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "User message"}] - }, - { - "type": "message", - "role": "assistant", - "content": [{"type": "input_text", "text": "Assistant message"}] - }, - { - "type": "message", - "role": "system", - "content": [{"type": "input_text", "text": "System message"}] - } - ] - })) - .await; - - assert_eq!(create_items_response.status_code(), 200); - let items_response = create_items_response.json::(); - assert_eq!(items_response.data.len(), 3); - - // Verify roles - assert_eq!(items_response.data[0].role(), "user"); - assert_eq!(items_response.data[1].role(), "assistant"); - assert_eq!(items_response.data[2].role(), "system"); - - println!("✅ Different roles test passed"); -} - -#[tokio::test] -async fn test_conversation_items_pagination() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Create 5 responses to generate multiple items (each response creates 2 items: user input + assistant output) - let max_tokens = 10; - for i in 0..5 { - let message = format!("Test message {}", i + 1); - create_response( - &server, - conversation.id.clone(), - model_id.clone(), - message, - max_tokens, - api_key.clone(), - ) - .await; - } - - // Test 1: Fetch first page with limit of 3 - let response = server - .get(format!("/v1/conversations/{}/items?limit=3", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response.status_code(), 200); - let first_page = response.json::(); - - // Should have exactly 3 items - assert_eq!(first_page.data.len(), 3, "First page should have 3 items"); - - // Should indicate there are more items - assert!(first_page.has_more, "Should indicate more items exist"); - - // Should have first_id and last_id - assert!(!first_page.first_id.is_empty(), "Should have first_id"); - assert!(!first_page.last_id.is_empty(), "Should have last_id"); - - // Test 2: Fetch next page using 'after' cursor - let last_id_from_page1 = first_page.last_id.clone(); - let response2 = server - .get( - format!( - "/v1/conversations/{}/items?limit=3&after={}", - conversation.id, last_id_from_page1 - ) - .as_str(), - ) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response2.status_code(), 200); - let second_page = response2.json::(); - - // Should have exactly 3 items - assert_eq!(second_page.data.len(), 3, "Second page should have 3 items"); - - // Should indicate there are more items - assert!(second_page.has_more, "Should indicate more items exist"); - - // Test 3: Fetch third page - let last_id_from_page2 = second_page.last_id.clone(); - let response3 = server - .get( - format!( - "/v1/conversations/{}/items?limit=3&after={}", - conversation.id, last_id_from_page2 - ) - .as_str(), - ) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response3.status_code(), 200); - let third_page = response3.json::(); - - // Should have 3 items (limited by the limit parameter) - assert_eq!(third_page.data.len(), 3, "Third page should have 3 items"); - - // Should indicate there is 1 more item - assert!(third_page.has_more, "Should indicate more items exist"); - - // Test 4: Fetch fourth (final) page - let last_id_from_page3 = third_page.last_id.clone(); - let response4 = server - .get( - format!( - "/v1/conversations/{}/items?limit=3&after={}", - conversation.id, last_id_from_page3 - ) - .as_str(), - ) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response4.status_code(), 200); - let fourth_page = response4.json::(); - - // Should have 1 item (the last one) - assert_eq!(fourth_page.data.len(), 1, "Fourth page should have 1 item"); - - // Should NOT indicate there are more items - assert!( - !fourth_page.has_more, - "Should NOT indicate more items exist" - ); - - // Test 5: Verify no duplicate items across pages - let all_ids: Vec = first_page - .data - .iter() - .chain(second_page.data.iter()) - .chain(third_page.data.iter()) - .chain(fourth_page.data.iter()) - .map(|item| match item { - ConversationItem::Message { id, .. } => id.clone(), - ConversationItem::ToolCall { id, .. } => id.clone(), - ConversationItem::WebSearchCall { id, .. } => id.clone(), - ConversationItem::Reasoning { id, .. } => id.clone(), - ConversationItem::McpListTools { id, .. } => id.clone(), - ConversationItem::McpCall { id, .. } => id.clone(), - ConversationItem::McpApprovalRequest { id, .. } => id.clone(), - ConversationItem::FunctionCall { id, .. } => id.clone(), - ConversationItem::FunctionCallOutput { id, .. } => id.clone(), - }) - .collect(); - - // Check for uniqueness - let unique_ids: std::collections::HashSet<_> = all_ids.iter().collect(); - assert_eq!( - all_ids.len(), - unique_ids.len(), - "All items should be unique across pages" - ); - - // Test 6: Fetch all items without pagination (default limit of 100) - let response_all = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response_all.status_code(), 200); - let all_items = response_all.json::(); - - // Should have all 10 items - assert_eq!( - all_items.data.len(), - 10, - "Should fetch all 10 items with default limit" - ); - - // Should NOT indicate there are more items - assert!( - !all_items.has_more, - "Should NOT indicate more items with all items fetched" - ); - - println!("✅ Conversation items pagination working correctly"); -} - -#[tokio::test] -async fn test_response_previous_next_relationships() { - use crate::common::mock_prompts; - use inference_providers::mock::{RequestMatcher, ResponseTemplate}; - - // Use setup_test_server_with_pool to get access to mock provider - let (server, _pool, mock, _db) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Created conversation: {}", conversation.id); - - // Set up mock expectations for each request in the conversation tree - // Parent request: "What is the capital of France?" - let parent_prompt = mock_prompts::build_prompt("What is the capital of France?"); - mock.when(RequestMatcher::ExactPrompt(parent_prompt)) - .respond_with(ResponseTemplate::new("Paris is the capital of France.")) - .await; - - // Create first response (parent) - let parent_response = create_response( - &server, - conversation.id.clone(), - "Qwen/Qwen3-30B-A3B-Instruct-2507".to_string(), - "What is the capital of France?".to_string(), - 100, - api_key.clone(), - ) - .await; - - println!("Created parent response: {}", parent_response.id); - - // Verify parent response has no next responses initially - assert!( - parent_response.next_response_ids.is_empty(), - "Parent response should have no next responses initially" - ); - assert!( - parent_response.previous_response_id.is_none(), - "Parent response should have no previous_response_id" - ); - - // Branch 1: First follow-up from parent - // Expected context: parent user + parent assistant + new user message - let response1_prompt = mock_prompts::build_prompt( - "What is the capital of France? Paris is the capital of France. Tell me more about that.", - ); - mock.when(RequestMatcher::ExactPrompt(response1_prompt)) - .respond_with(ResponseTemplate::new( - "Paris is known for the Eiffel Tower and rich history.", - )) - .await; - - // Create first follow-up response (with conversation + previous_response_id for context filtering) - let response1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id - }, - "input": "Tell me more about that.", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "previous_response_id": parent_response.id - })) - .await; - - assert_eq!(response1.status_code(), 200); - let response1 = response1.json::(); - println!("Created response1: {}", response1.id); - - // Verify response1 has parent reference - assert_eq!( - response1.previous_response_id, - Some(parent_response.id.clone()), - "Response1 should reference parent as previous_response_id" - ); - assert!( - response1.next_response_ids.is_empty(), - "Response1 should have no next responses initially" - ); - - // Branch 2: Second follow-up from parent (creates a sibling branch) - // Expected context: parent user + parent assistant + new user message - // This should be the SAME context as response1 (both branch from parent) - let response2_prompt = mock_prompts::build_prompt( - "What is the capital of France? Paris is the capital of France. What about its history?", - ); - mock.when(RequestMatcher::ExactPrompt(response2_prompt)) - .respond_with(ResponseTemplate::new( - "Paris has a long history dating back to Roman times.", - )) - .await; - - // Create second follow-up response from the same parent (with conversation + previous_response_id) - let response2 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id - }, - "input": "What about its history?", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "previous_response_id": parent_response.id - })) - .await; - - assert_eq!(response2.status_code(), 200); - let response2 = response2.json::(); - println!("Created response2: {}", response2.id); - - // Verify response2 has parent reference - assert_eq!( - response2.previous_response_id, - Some(parent_response.id.clone()), - "Response2 should reference parent as previous_response_id" - ); - - // CRITICAL TEST: Nested response following branch 1 - // This verifies that context filtering works correctly - // Expected context: parent → response1 path ONLY (should NOT include response2) - // Format: parent_user + parent_assistant + response1_user + response1_assistant + nested_user - let nested_prompt = mock_prompts::build_prompt( - "What is the capital of France? Paris is the capital of France. Tell me more about that. Paris is known for the Eiffel Tower and rich history. Can you elaborate?" - ); - mock.when(RequestMatcher::ExactPrompt(nested_prompt)) - .respond_with(ResponseTemplate::new( - "The Eiffel Tower was built in 1889 for the World's Fair.", - )) - .await; - - // Create nested response (follows response1, with conversation + previous_response_id for filtering) - let nested_response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id - }, - "input": "Can you elaborate?", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "previous_response_id": response1.id - })) - .await; - - assert_eq!( - nested_response.status_code(), - 200, - "Nested response should succeed" - ); - let nested_response = nested_response.json::(); - println!("Created nested response: {}", nested_response.id); - - // Verify nested response has response1 as previous - assert_eq!( - nested_response.previous_response_id, - Some(response1.id.clone()), - "Nested response should reference response1 as previous_response_id" - ); - - // Verify the response content to ensure the mock was matched - // This confirms that the correct context was passed (parent → response1 path, excluding response2) - let has_expected_content = nested_response.output.iter().any(|item| { - if let api::models::ResponseOutputItem::Message { content, .. } = item { - content.iter().any(|part| { - if let api::models::ResponseOutputContent::OutputText { text, .. } = part { - text.contains("Eiffel Tower was built in 1889") - } else { - false - } - }) - } else { - false - } - }); - assert!( - has_expected_content, - "Nested response should contain expected content from the mock, confirming correct context filtering" - ); - - println!("✅ Response previous-next relationships and context filtering working correctly"); - println!(" - Parent: {}", parent_response.id); - println!( - " - Response1: {} (previous: {})", - response1.id, - response1.previous_response_id.as_ref().unwrap() - ); - println!( - " - Response2: {} (previous: {})", - response2.id, - response2.previous_response_id.as_ref().unwrap() - ); - println!( - " - Nested: {} (previous: {}) - context filtered to parent→response1 path only", - nested_response.id, - nested_response.previous_response_id.as_ref().unwrap() - ); -} - -#[tokio::test] -async fn test_first_turn_items_have_root_response_parent() { - let server = setup_test_server().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Created conversation: {}", conversation.id); - - // Create the first response in this conversation (no previous_response_id) - let first_response = create_response( - &server, - conversation.id.clone(), - "Qwen/Qwen3-30B-A3B-Instruct-2507".to_string(), - "Hello, world!".to_string(), - 100, - api_key.clone(), - ) - .await; - - println!("Created first response: {}", first_response.id); - - // List conversation items and verify that all items belonging to the first - // response share a non-empty previous_response_id that is different from - // the response_id itself (i.e., they point to the hidden root_response). - let items_list = - list_conversation_items(&server, conversation.id.clone(), api_key.clone()).await; - assert!( - !items_list.data.is_empty(), - "Conversation should contain at least the first turn items" - ); - - let mut parent_ids: Vec = Vec::new(); - - for item in &items_list.data { - match item { - api::models::ConversationItem::Message { - response_id, - previous_response_id, - .. - } - | api::models::ConversationItem::Reasoning { - response_id, - previous_response_id, - .. - } => { - if response_id == &first_response.id { - let prev = previous_response_id - .as_ref() - .unwrap_or_else(|| panic!( - "First-turn item {} should have a previous_response_id (root_response parent)", - response_id - )); - parent_ids.push(prev.clone()); - } - } - _ => {} - } - } - - assert!( - !parent_ids.is_empty(), - "Expected at least one item belonging to the first response" - ); - - // All parent IDs should be the same and different from the first response ID. - let root_id = &parent_ids[0]; - for pid in &parent_ids { - assert_eq!( - pid, root_id, - "All first-turn items should share the same root_response parent" - ); - } - assert_ne!( - root_id, &first_response.id, - "root_response parent ID should be different from the first response ID" - ); -} - -#[tokio::test] -async fn test_first_turn_regenerate_creates_siblings_under_root_response() { - use std::collections::HashSet; - - let server = setup_test_server().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation and the first response (no previous_response_id) - let conversation = create_conversation(&server, api_key.clone()).await; - let first_response = create_response( - &server, - conversation.id.clone(), - "Qwen/Qwen3-30B-A3B-Instruct-2507".to_string(), - "Hello, root!".to_string(), - 100, - api_key.clone(), - ) - .await; - - // Fetch items and extract the root_response ID from one of the first-turn items - let items_list = - list_conversation_items(&server, conversation.id.clone(), api_key.clone()).await; - let root_response_id = items_list - .data - .iter() - .find_map(|item| match item { - api::models::ConversationItem::Message { - response_id, - previous_response_id, - .. - } - | api::models::ConversationItem::Reasoning { - response_id, - previous_response_id, - .. - } if response_id == &first_response.id => previous_response_id.clone(), - _ => None, - }) - .expect("Expected first-turn items to have a root_response previous_response_id"); - - println!( - "First response {} has root_response parent {}", - first_response.id, root_response_id - ); - - // Create a second first-turn response by using the root_response ID as previous_response_id. - // This simulates "regenerate" of the first turn: both responses share the same root parent. - let regen_response_http = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "input": "Hello again!", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "previous_response_id": root_response_id - })) - .await; - - assert_eq!(regen_response_http.status_code(), 200); - let regen_response = regen_response_http.json::(); - println!( - "Created regenerated first-turn response: {}", - regen_response.id - ); - - // List items again and collect all distinct response_ids that share the same - // root_response parent. We expect at least the original first_response and - // the regenerated response. - let items_after = - list_conversation_items(&server, conversation.id.clone(), api_key.clone()).await; - let mut first_turn_response_ids: HashSet = HashSet::new(); - - for item in &items_after.data { - match item { - api::models::ConversationItem::Message { - response_id, - previous_response_id, - .. - } - | api::models::ConversationItem::Reasoning { - response_id, - previous_response_id, - .. - } => { - if previous_response_id.as_deref() == Some(&root_response_id) { - first_turn_response_ids.insert(response_id.clone()); - } - } - _ => {} - } - } - - assert!( - first_turn_response_ids.contains(&first_response.id), - "Original first response should be a child of root_response" - ); - assert!( - first_turn_response_ids.contains(®en_response.id), - "Regenerated first-turn response should also be a child of root_response" - ); - assert!( - first_turn_response_ids.len() >= 2, - "Expected at least two distinct first-turn responses under the same root_response" - ); -} - -#[tokio::test] -async fn test_response_previous_next_relationships_streaming() { - let server = setup_test_server().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Created conversation: {}", conversation.id); - - // Create first response (parent) with streaming - let (_, parent_response) = create_response_stream( - &server, - conversation.id.clone(), - "Qwen/Qwen3-30B-A3B-Instruct-2507".to_string(), - "What is the capital of France?".to_string(), - 100, - api_key.clone(), - ) - .await; - - println!( - "Created parent response (streaming): {}", - parent_response.id - ); - - // Verify parent response has no next responses initially - assert!( - parent_response.next_response_ids.is_empty(), - "Parent response should have no next responses initially" - ); - - // Create follow-up response with streaming (conversation inherited from parent) - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "input": "Tell me more about that.", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": true, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "previous_response_id": parent_response.id - })) - .await; - - if response.status_code() != 200 { - println!("Error response: {}", response.text()); - } - assert_eq!(response.status_code(), 200); - - // Parse streaming response - let response_text = response.text(); - let mut next_response: Option = None; - - for line_chunk in response_text.split("\n\n") { - if line_chunk.trim().is_empty() { - continue; - } - - let mut event_type = ""; - let mut event_data = ""; - - for line in line_chunk.lines() { - if let Some(event_name) = line.strip_prefix("event: ") { - event_type = event_name; - } else if let Some(data) = line.strip_prefix("data: ") { - event_data = data; - } - } - - if !event_data.is_empty() && event_type == "response.completed" { - if let Ok(event_json) = serde_json::from_str::(event_data) { - if let Some(response_obj) = event_json.get("response") { - next_response = Some( - serde_json::from_value::(response_obj.clone()) - .expect("Failed to parse response.completed event"), - ); - } - } - } - } - - let follow_up_response = next_response.expect("Should have received completed response"); - println!( - "Created follow-up response (streaming): {}", - follow_up_response.id - ); - - // Verify follow-up has parent reference - assert_eq!( - follow_up_response.previous_response_id, - Some(parent_response.id.clone()), - "Follow-up should reference parent as previous_response_id" - ); - assert!( - follow_up_response.next_response_ids.is_empty(), - "Follow-up should have no next responses initially" - ); - - println!("✅ Response previous-next relationships working correctly with streaming"); - println!(" - Parent: {}", parent_response.id); - println!( - " - Follow-up: {} (previous: {})", - follow_up_response.id, - follow_up_response.previous_response_id.as_ref().unwrap() - ); -} - -#[tokio::test] -async fn test_conversation_items_include_response_metadata() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Create parent response - let parent_response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "input": "What is Rust?", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": conversation.id - })) - .await; - - assert_eq!(parent_response.status_code(), 200); - let parent_response = parent_response.json::(); - println!("Created parent response: {}", parent_response.id); - - // Create follow-up response - let follow_up_response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "input": "Tell me more about that.", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "previous_response_id": parent_response.id - })) - .await; - - assert_eq!(follow_up_response.status_code(), 200); - let follow_up_response = follow_up_response.json::(); - println!("Created follow-up response: {}", follow_up_response.id); - - // List conversation items - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items_list = items_response.json::(); - - println!("Retrieved {} conversation items", items_list.data.len()); - - // Verify each item has the new metadata fields - for item in &items_list.data { - match item { - api::models::ConversationItem::Message { - id, - response_id, - previous_response_id, - next_response_ids, - created_at, - .. - } => { - println!(" Item {id}: response_id={response_id}, previous_response_id={previous_response_id:?}, next_response_ids={next_response_ids:?}, created_at={created_at}"); - - // Verify required fields are populated - assert!(!response_id.is_empty(), "response_id should not be empty"); - assert!(*created_at > 0, "created_at should be a valid timestamp"); - - // If this item belongs to the follow-up response, verify it has the parent's ID - if response_id == &follow_up_response.id { - assert_eq!( - previous_response_id.as_ref(), - Some(&parent_response.id), - "Follow-up response item should have parent's ID in previous_response_id" - ); - } - } - _ => { - // For non-message items, just verify they have the metadata - // (could add similar checks for ToolCall, WebSearchCall, Reasoning) - } - } - } - - // Verify items are sorted by created_at (ascending order) - let mut prev_timestamp = 0i64; - for item in &items_list.data { - let current_timestamp = match item { - api::models::ConversationItem::Message { created_at, .. } => Some(*created_at), - api::models::ConversationItem::ToolCall { created_at, .. } => Some(*created_at), - api::models::ConversationItem::WebSearchCall { created_at, .. } => Some(*created_at), - api::models::ConversationItem::Reasoning { created_at, .. } => Some(*created_at), - api::models::ConversationItem::FunctionCall { created_at, .. } => Some(*created_at), - api::models::ConversationItem::FunctionCallOutput { created_at, .. } => { - Some(*created_at) - } - // MCP items don't have created_at field in the API model - api::models::ConversationItem::McpListTools { .. } => None, - api::models::ConversationItem::McpCall { .. } => None, - api::models::ConversationItem::McpApprovalRequest { .. } => None, - }; - if let Some(ts) = current_timestamp { - assert!( - ts >= prev_timestamp, - "Items should be sorted by created_at in ascending order" - ); - prev_timestamp = ts; - } - } - - println!("✅ Conversation items include response metadata (response_id, previous_response_id, next_response_ids, created_at)"); - println!("✅ Items are sorted by created_at in ascending order"); -} - -// ============================================ -// Conversation Management Tests (Pin, Archive, Clone, Rename, Delete) -// ============================================ - -#[tokio::test] -async fn test_pin_unpin_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Created conversation: {}", conversation.id); - - // Verify conversation is not pinned initially - let get_response = server - .get(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(get_response.status_code(), 200); - let conv = get_response.json::(); - assert_eq!(conv.object, "conversation"); - - // Test: Pin the conversation - let now = chrono::Utc::now().timestamp(); - let pin_response = server - .post(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_response.status_code(), 200); - let pinned_conv = pin_response.json::(); - - // Verify pinned_at is present and valid - let pinned_at = pinned_conv - .metadata - .get("pinned_at") - .expect("pinned_at should be present in metadata") - .as_i64() - .expect("pinned_at should be a number"); - assert!( - pinned_at >= now, - "pinned_at ({pinned_at}) should be >= now ({now})" - ); - assert_eq!(pinned_conv.id, conversation.id); - - // Verify archived_at is not present when only pinned - assert!( - pinned_conv.metadata.get("archived_at").is_none(), - "archived_at should not be present for pinned-only conversation" - ); - println!("✅ Conversation pinned successfully with pinned_at timestamp"); - - // Test: Unpin the conversation - let unpin_response = server - .delete(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(unpin_response.status_code(), 200); - let unpinned_conv = unpin_response.json::(); - assert_eq!(unpinned_conv.id, conversation.id); - - // Verify pinned_at is removed after unpinning - assert!( - unpinned_conv.metadata.get("pinned_at").is_none(), - "pinned_at should not be present after unpinning" - ); - println!("✅ Conversation unpinned successfully, pinned_at removed"); - - // Test: Pinning again should be idempotent - let now2 = chrono::Utc::now().timestamp(); - let pin_again_response = server - .post(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_again_response.status_code(), 200); - let repinned_conv = pin_again_response.json::(); - - // Verify pinned_at is present again - let repinned_at = repinned_conv - .metadata - .get("pinned_at") - .expect("pinned_at should be present after re-pinning") - .as_i64() - .expect("pinned_at should be a number"); - assert!( - repinned_at >= now2, - "pinned_at should be updated to current time on re-pin" - ); - println!("✅ Pin operation is idempotent and updates pinned_at timestamp"); -} - -#[tokio::test] -async fn test_archive_unarchive_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Created conversation: {}", conversation.id); - - // Test: Archive the conversation - let now = chrono::Utc::now().timestamp(); - let archive_response = server - .post(format!("/v1/conversations/{}/archive", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(archive_response.status_code(), 200); - let archived_conv = archive_response.json::(); - assert_eq!(archived_conv.id, conversation.id); - - // Verify archived_at is present and valid - let archived_at = archived_conv - .metadata - .get("archived_at") - .expect("archived_at should be present in metadata") - .as_i64() - .expect("archived_at should be a number"); - assert!( - archived_at >= now, - "archived_at ({archived_at}) should be >= now ({now})" - ); - - // Verify pinned_at is not present when only archived - assert!( - archived_conv.metadata.get("pinned_at").is_none(), - "pinned_at should not be present for archived-only conversation" - ); - println!("✅ Conversation archived successfully with archived_at timestamp"); - - // Test: Unarchive the conversation - let unarchive_response = server - .delete(format!("/v1/conversations/{}/archive", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(unarchive_response.status_code(), 200); - let unarchived_conv = unarchive_response.json::(); - assert_eq!(unarchived_conv.id, conversation.id); - - // Verify archived_at is removed after unarchiving - assert!( - unarchived_conv.metadata.get("archived_at").is_none(), - "archived_at should not be present after unarchiving" - ); - println!("✅ Conversation unarchived successfully, archived_at removed"); - - // Test: Archiving again should be idempotent - let now2 = chrono::Utc::now().timestamp(); - let archive_again_response = server - .post(format!("/v1/conversations/{}/archive", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(archive_again_response.status_code(), 200); - let rearchived_conv = archive_again_response.json::(); - - // Verify archived_at is present again - let rearchived_at = rearchived_conv - .metadata - .get("archived_at") - .expect("archived_at should be present after re-archiving") - .as_i64() - .expect("archived_at should be a number"); - assert!( - rearchived_at >= now2, - "archived_at should be updated to current time on re-archive" - ); - println!("✅ Archive operation is idempotent and updates archived_at timestamp"); -} - -#[tokio::test] -async fn test_rename_conversation_via_metadata() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation with initial title in metadata - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Original Title", - "description": "Test conversation" - } - })) - .await; - assert_eq!(create_response.status_code(), 201); - let conversation = create_response.json::(); - println!("Created conversation: {}", conversation.id); - - // Verify initial metadata - assert_eq!( - conversation.metadata.get("title").and_then(|v| v.as_str()), - Some("Original Title") - ); - - // Test: Update conversation name via metadata - let update_response = server - .post(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Updated Title", - "description": "Updated description" - } - })) - .await; - assert_eq!(update_response.status_code(), 200); - let updated_conv = update_response.json::(); - - // Verify updated metadata - assert_eq!( - updated_conv.metadata.get("title").and_then(|v| v.as_str()), - Some("Updated Title") - ); - assert_eq!( - updated_conv - .metadata - .get("description") - .and_then(|v| v.as_str()), - Some("Updated description") - ); - println!("✅ Conversation renamed via metadata update"); - - // Test: Get conversation to verify persistence - let get_response = server - .get(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(get_response.status_code(), 200); - let fetched_conv = get_response.json::(); - assert_eq!( - fetched_conv.metadata.get("title").and_then(|v| v.as_str()), - Some("Updated Title") - ); - println!("✅ Metadata changes persisted"); -} - -#[tokio::test] -async fn test_pin_rename_unpin_conversation() { - // Test the bug: pin -> rename -> unpin should remove pinned_at from metadata - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Test Conversation" - } - })) - .await; - assert_eq!(create_response.status_code(), 201); - let conversation = create_response.json::(); - println!("Created conversation: {}", conversation.id); - - // Step 1: Pin the conversation - let pin_response = server - .post(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_response.status_code(), 200); - let pinned_conv = pin_response.json::(); - - // Verify pinned_at is present - assert!( - pinned_conv.metadata.get("pinned_at").is_some(), - "pinned_at should be present after pinning" - ); - println!("✅ Conversation pinned successfully"); - - // Step 2: Rename the conversation (update metadata) - let rename_response = server - .post(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Renamed Conversation" - } - })) - .await; - assert_eq!(rename_response.status_code(), 200); - let renamed_conv = rename_response.json::(); - - // Verify title was updated - assert_eq!( - renamed_conv.metadata.get("title").and_then(|v| v.as_str()), - Some("Renamed Conversation") - ); - // Verify pinned_at is still present after rename - assert!( - renamed_conv.metadata.get("pinned_at").is_some(), - "pinned_at should still be present after rename" - ); - println!("✅ Conversation renamed successfully, pinned_at preserved"); - - // Step 3: Unpin the conversation - let unpin_response = server - .delete(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(unpin_response.status_code(), 200); - let unpinned_conv = unpin_response.json::(); - - // Verify pinned_at is removed after unpinning (this was the bug) - assert!( - unpinned_conv.metadata.get("pinned_at").is_none(), - "pinned_at should be removed from metadata after unpinning, even after rename" - ); - // Verify title is still preserved - assert_eq!( - unpinned_conv.metadata.get("title").and_then(|v| v.as_str()), - Some("Renamed Conversation") - ); - println!("✅ Conversation unpinned successfully, pinned_at removed from metadata"); -} - -#[tokio::test] -async fn test_clone_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation with metadata - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Original Conversation", - "custom_field": "custom_value" - } - })) - .await; - assert_eq!(create_response.status_code(), 201); - let original_conv = create_response.json::(); - println!("Created original conversation: {}", original_conv.id); - - // Test: Clone the conversation - let clone_response = server - .post(format!("/v1/conversations/{}/clone", original_conv.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_response.status_code(), 201); - let cloned_conv = clone_response.json::(); - - // Verify cloned conversation has different ID - assert_ne!(cloned_conv.id, original_conv.id); - println!("✅ Cloned conversation has new ID: {}", cloned_conv.id); - - // Verify cloned conversation has " (Copy)" appended to title - let cloned_title = cloned_conv.metadata.get("title").and_then(|v| v.as_str()); - assert_eq!(cloned_title, Some("Original Conversation (Copy)")); - println!("✅ Cloned conversation title has ' (Copy)' appended"); - - // Verify other metadata is preserved - assert_eq!( - cloned_conv - .metadata - .get("custom_field") - .and_then(|v| v.as_str()), - Some("custom_value") - ); - println!("✅ Other metadata preserved in clone"); - - // Test: Clone without title in metadata - let create_no_title_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "custom_field": "value" - } - })) - .await; - assert_eq!(create_no_title_response.status_code(), 201); - let no_title_conv = create_no_title_response.json::(); - - let clone_no_title_response = server - .post(format!("/v1/conversations/{}/clone", no_title_conv.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_no_title_response.status_code(), 201); - let cloned_no_title = clone_no_title_response.json::(); - - // Verify metadata is still copied even without title - assert_eq!( - cloned_no_title - .metadata - .get("custom_field") - .and_then(|v| v.as_str()), - Some("value") - ); - println!("✅ Clone works correctly without title in metadata"); -} - -#[tokio::test] -async fn test_clone_conversation_with_responses_and_items() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation with metadata - let conv_create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Original Conversation with Messages", - "description": "Test deep clone" - } - })) - .await; - assert_eq!(conv_create_response.status_code(), 201); - let original_conv = conv_create_response.json::(); - println!("Created original conversation: {}", original_conv.id); - - let response1 = create_response( - &server, - original_conv.id.clone(), - model_id.clone(), - "First message in conversation".to_string(), - 50, - api_key.clone(), - ) - .await; - println!("Created response 1: {}", response1.id); - - let response2 = create_response( - &server, - original_conv.id.clone(), - model_id.clone(), - "Second message in conversation".to_string(), - 50, - api_key.clone(), - ) - .await; - println!("Created response 2: {}", response2.id); - - // Get original conversation items count - let original_items = - list_conversation_items(&server, original_conv.id.clone(), api_key.clone()).await; - let original_item_count = original_items.data.len(); - println!("Original conversation has {original_item_count} items"); - assert!( - original_item_count >= 4, - "Should have at least 4 items (2 user messages + 2 assistant responses)" - ); - - // Clone the conversation (deep clone) - let clone_response = server - .post(format!("/v1/conversations/{}/clone", original_conv.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_response.status_code(), 201); - let cloned_conv = clone_response.json::(); - println!("Cloned conversation: {}", cloned_conv.id); - - // Verify cloned conversation has different ID - assert_ne!(cloned_conv.id, original_conv.id); - - // Verify title has " (Copy)" appended - assert_eq!( - cloned_conv.metadata.get("title").and_then(|v| v.as_str()), - Some("Original Conversation with Messages (Copy)") - ); - - // Get cloned conversation items - let cloned_items = - list_conversation_items(&server, cloned_conv.id.clone(), api_key.clone()).await; - let cloned_item_count = cloned_items.data.len(); - println!("Cloned conversation has {cloned_item_count} items"); - - // Verify the clone has the same number of items as the original - assert_eq!( - cloned_item_count, original_item_count, - "Cloned conversation should have the same number of items as original" - ); - - // Verify the content of items is the same (but with different IDs) - for (orig_item, cloned_item) in original_items.data.iter().zip(cloned_items.data.iter()) { - // IDs should be different - assert_ne!( - orig_item.id(), - cloned_item.id(), - "Item IDs should be different" - ); - - // Content should be the same - if let ( - api::models::ConversationItem::Message { - content: orig_content, - role: orig_role, - .. - }, - api::models::ConversationItem::Message { - content: cloned_content, - role: cloned_role, - .. - }, - ) = (orig_item, cloned_item) - { - assert_eq!(orig_role, cloned_role, "Roles should match"); - assert_eq!( - orig_content.len(), - cloned_content.len(), - "Content parts count should match" - ); - - // Compare text content - for (orig_part, cloned_part) in orig_content.iter().zip(cloned_content.iter()) { - match (orig_part, cloned_part) { - ( - api::models::ConversationContentPart::InputText { text: orig_text }, - api::models::ConversationContentPart::InputText { text: cloned_text }, - ) => { - assert_eq!(orig_text, cloned_text, "Text content should match"); - } - ( - api::models::ConversationContentPart::OutputText { - text: orig_text, .. - }, - api::models::ConversationContentPart::OutputText { - text: cloned_text, .. - }, - ) => { - assert_eq!(orig_text, cloned_text, "Output text should match"); - } - _ => {} // Other content types - } - } - } - // Other item types (tool calls, etc.) - } - - println!("✅ Deep clone successfully copied all responses and items"); - println!("✅ Cloned items have different IDs but same content"); - - // Verify that modifying the clone doesn't affect the original - let update_clone = server - .post(format!("/v1/conversations/{}", cloned_conv.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Modified Clone", - "description": "Changed description" - } - })) - .await; - assert_eq!(update_clone.status_code(), 200); - - // Get original conversation to verify it wasn't changed - let get_original = server - .get(format!("/v1/conversations/{}", original_conv.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(get_original.status_code(), 200); - let unchanged_original = get_original.json::(); - - assert_eq!( - unchanged_original - .metadata - .get("title") - .and_then(|v| v.as_str()), - Some("Original Conversation with Messages"), - "Original conversation title should be unchanged" - ); - - println!("✅ Clone is independent - modifying clone doesn't affect original"); -} - -#[tokio::test] -async fn test_clone_pinned_and_archived_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Test Conversation", - "custom_field": "custom_value" - } - })) - .await; - assert_eq!(create_response.status_code(), 201); - let conversation = create_response.json::(); - println!("Created conversation: {}", conversation.id); - - // Pin the conversation - let pin_response = server - .post(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_response.status_code(), 200); - println!("✅ Pinned conversation"); - - // Archive the conversation - let archive_response = server - .post(format!("/v1/conversations/{}/archive", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(archive_response.status_code(), 200); - let pinned_archived_conv = archive_response.json::(); - - // Verify both pinned_at and archived_at are present - assert!( - pinned_archived_conv.metadata.get("pinned_at").is_some(), - "Original should have pinned_at" - ); - assert!( - pinned_archived_conv.metadata.get("archived_at").is_some(), - "Original should have archived_at" - ); - println!("✅ Conversation is both pinned and archived"); - - // Clone the pinned and archived conversation - let clone_response = server - .post(format!("/v1/conversations/{}/clone", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_response.status_code(), 201); - let cloned_conv = clone_response.json::(); - - // Verify cloned conversation has different ID - assert_ne!(cloned_conv.id, conversation.id); - println!("✅ Cloned conversation has new ID: {}", cloned_conv.id); - - // Verify cloned conversation does NOT inherit pinned_at or archived_at - assert!( - cloned_conv.metadata.get("pinned_at").is_none(), - "Clone should NOT inherit pinned_at from original" - ); - assert!( - cloned_conv.metadata.get("archived_at").is_none(), - "Clone should NOT inherit archived_at from original" - ); - - // Verify other metadata is preserved - assert_eq!( - cloned_conv.metadata.get("title").and_then(|v| v.as_str()), - Some("Test Conversation (Copy)"), - "Clone should have title with (Copy) appended" - ); - assert_eq!( - cloned_conv - .metadata - .get("custom_field") - .and_then(|v| v.as_str()), - Some("custom_value"), - "Clone should preserve other metadata fields" - ); - - println!("✅ Clone does not inherit pinned_at or archived_at"); - println!("✅ Clone starts fresh without pin/archive state"); -} - -#[tokio::test] -async fn test_delete_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Created conversation: {}", conversation.id); - - // Verify conversation exists - let get_response = server - .get(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(get_response.status_code(), 200); - - // Test: Delete the conversation - let delete_response = server - .delete(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(delete_response.status_code(), 200); - let delete_result = delete_response.json::(); - assert_eq!(delete_result.id, conversation.id); - assert_eq!(delete_result.object, "conversation.deleted"); - assert!(delete_result.deleted); - println!("✅ Conversation deleted successfully"); - - // Test: Getting deleted conversation should return 404 - let get_deleted_response = server - .get(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(get_deleted_response.status_code(), 404); - println!("✅ Deleted conversation returns 404"); - - // Test: Deleting non-existent conversation should return 404 - let fake_id = "conv_00000000-0000-0000-0000-000000000000"; - let delete_fake_response = server - .delete(format!("/v1/conversations/{fake_id}").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(delete_fake_response.status_code(), 404); - println!("✅ Deleting non-existent conversation returns 404"); -} - -#[tokio::test] -async fn test_pin_nonexistent_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let fake_id = "conv_00000000-0000-0000-0000-000000000000"; - - // Test: Pinning non-existent conversation should return 404 - let pin_response = server - .post(format!("/v1/conversations/{fake_id}/pin").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_response.status_code(), 404); - println!("✅ Pinning non-existent conversation returns 404"); -} - -#[tokio::test] -async fn test_archive_nonexistent_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let fake_id = "conv_00000000-0000-0000-0000-000000000000"; - - // Test: Archiving non-existent conversation should return 404 - let archive_response = server - .post(format!("/v1/conversations/{fake_id}/archive").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(archive_response.status_code(), 404); - println!("✅ Archiving non-existent conversation returns 404"); -} - -#[tokio::test] -async fn test_clone_nonexistent_conversation() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let fake_id = "conv_00000000-0000-0000-0000-000000000000"; - - // Test: Cloning non-existent conversation should return 404 - let clone_response = server - .post(format!("/v1/conversations/{fake_id}/clone").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_response.status_code(), 404); - println!("✅ Cloning non-existent conversation returns 404"); -} +use crate::common::*; +use axum::http::Method; #[tokio::test] -async fn test_pin_and_archive_together() { +async fn retired_conversation_routes_require_an_api_key_then_return_gone() { let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - println!("Created conversation: {}", conversation.id); - - // Pin the conversation first - let pin_now = chrono::Utc::now().timestamp(); - let pin_response = server - .post(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_response.status_code(), 200); - let pinned_conv = pin_response.json::(); - - // Verify pinned_at is present - assert!(pinned_conv.metadata.get("pinned_at").is_some()); - assert!(pinned_conv.metadata.get("archived_at").is_none()); - println!("✅ Conversation pinned"); - - // Archive the pinned conversation - let archive_now = chrono::Utc::now().timestamp(); - let archive_response = server - .post(format!("/v1/conversations/{}/archive", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(archive_response.status_code(), 200); - let archived_pinned_conv = archive_response.json::(); - - // Verify both pinned_at and archived_at are present - let pinned_at = archived_pinned_conv - .metadata - .get("pinned_at") - .expect("pinned_at should still be present after archiving") - .as_i64() - .expect("pinned_at should be a number"); - let archived_at = archived_pinned_conv - .metadata - .get("archived_at") - .expect("archived_at should be present") - .as_i64() - .expect("archived_at should be a number"); - - assert!( - pinned_at >= pin_now, - "pinned_at should be from when conversation was pinned" - ); - assert!( - archived_at >= archive_now, - "archived_at should be from when conversation was archived" - ); - println!("✅ Conversation can be both pinned and archived"); - - // Unpin the archived conversation - let unpin_response = server - .delete(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(unpin_response.status_code(), 200); - let unpinned_archived_conv = unpin_response.json::(); - // Verify pinned_at is removed but archived_at remains - assert!( - unpinned_archived_conv.metadata.get("pinned_at").is_none(), - "pinned_at should be removed after unpinning" - ); - assert!( - unpinned_archived_conv.metadata.get("archived_at").is_some(), - "archived_at should still be present" - ); - println!("✅ Unpinning removes pinned_at but keeps archived_at"); + let missing_auth = server.post("/v1/conversations").await; + assert_eq!(missing_auth.status_code(), 401); - // Unarchive to clean up - let unarchive_response = server - .delete(format!("/v1/conversations/{}/archive", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(unarchive_response.status_code(), 200); - let unarchived_conv = unarchive_response.json::(); - - // Verify both are removed - assert!( - unarchived_conv.metadata.get("pinned_at").is_none(), - "pinned_at should not be present" - ); - assert!( - unarchived_conv.metadata.get("archived_at").is_none(), - "archived_at should be removed after unarchiving" - ); - println!("✅ Unarchiving removes archived_at"); -} - -#[tokio::test] -async fn test_combined_conversation_operations() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create a conversation with metadata - let create_response = server + let invalid_auth = server .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Test Conversation", - "tags": ["important", "work"] - } - })) - .await; - assert_eq!(create_response.status_code(), 201); - let conversation = create_response.json::(); - println!("Created conversation: {}", conversation.id); - - // Pin the conversation - let pin_response = server - .post(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_response.status_code(), 200); - let pinned_conv = pin_response.json::(); - - // Verify pinned_at is present - assert!( - pinned_conv.metadata.get("pinned_at").is_some(), - "pinned_at should be present after pinning" - ); - println!("✅ Pinned conversation with timestamp"); - - // Update metadata (rename) - let update_response = server - .post(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": { - "title": "Renamed Conversation", - "tags": ["important", "work", "updated"] - } - })) - .await; - assert_eq!(update_response.status_code(), 200); - println!("✅ Updated metadata"); - - // Clone the conversation - let clone_response = server - .post(format!("/v1/conversations/{}/clone", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_response.status_code(), 201); - let cloned_conv = clone_response.json::(); - - // Verify clone has updated metadata with " (Copy)" appended - assert_eq!( - cloned_conv.metadata.get("title").and_then(|v| v.as_str()), - Some("Renamed Conversation (Copy)") - ); - println!("✅ Cloned conversation has correct title"); - - // Archive the original conversation - let archive_response = server - .post(format!("/v1/conversations/{}/archive", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(archive_response.status_code(), 200); - let archived_conv = archive_response.json::(); - - // Verify archived_at is present - assert!( - archived_conv.metadata.get("archived_at").is_some(), - "archived_at should be present after archiving" - ); - // pinned_at should also still be present from earlier pin operation - assert!( - archived_conv.metadata.get("pinned_at").is_some(), - "pinned_at should still be present after archiving" - ); - println!("✅ Archived original conversation with timestamp"); - - // Delete the cloned conversation - let delete_response = server - .delete(format!("/v1/conversations/{}", cloned_conv.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(delete_response.status_code(), 200); - println!("✅ Deleted cloned conversation"); - - // Original conversation should still exist (just archived and pinned) - let get_response = server - .get(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) + .add_header("Authorization", "Bearer sk-invalid") .await; - assert_eq!(get_response.status_code(), 200); - println!("✅ Original conversation still exists after operations"); - - println!("✅ All combined operations completed successfully"); -} + assert_eq!(invalid_auth.status_code(), 401); -#[tokio::test] -async fn test_conversation_metadata_limits() { - let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; + let routes = [ + (Method::POST, "/v1/conversations"), + (Method::GET, "/v1/conversations/"), + (Method::POST, "/v1/conversations/batch"), + (Method::GET, "/v1/conversations/conv_example"), + (Method::POST, "/v1/conversations/conv_example"), + (Method::DELETE, "/v1/conversations/conv_example"), + (Method::POST, "/v1/conversations/conv_example/pin"), + (Method::DELETE, "/v1/conversations/conv_example/pin"), + (Method::POST, "/v1/conversations/conv_example/archive"), + (Method::DELETE, "/v1/conversations/conv_example/archive"), + (Method::POST, "/v1/conversations/conv_example/clone"), + (Method::GET, "/v1/conversations/conv_example/items"), + (Method::POST, "/v1/conversations/conv_example/items"), + (Method::PATCH, "/v1/conversations/conv_example/unknown"), + ]; - // Test: Create conversation with metadata containing multiple key-value pairs - let mut metadata = serde_json::Map::new(); - for i in 0..16 { - metadata.insert( - format!("key{i}"), - serde_json::Value::String(format!("value{i}")), - ); - } - - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "metadata": metadata - })) - .await; - assert_eq!(create_response.status_code(), 201); - let conversation = create_response.json::(); + for (method, path) in routes { + let response = server + .method(method.clone(), path) + .add_header("Authorization", format!("Bearer {api_key}")) + .await; - let meta = conversation.metadata.as_object().unwrap(); - // All 16 user-provided keys must be present (OpenAI limit) - for i in 0..16 { - let key = format!("key{i}"); - let expected = format!("value{i}"); assert_eq!( - meta.get(&key).and_then(|v| v.as_str()), - Some(expected.as_str()), - "key{i} missing or wrong value" + response.status_code(), + 410, + "{method} {path} must return 410 Gone after authentication" ); - } - // Response may include system keys (e.g. root_response_id for new conversations) - assert!( - meta.len() >= 16, - "metadata must contain at least the 16 user keys, got {}", - meta.len() - ); - println!("✅ Conversation created with 16 metadata keys (OpenAI limit)"); - - // Note: OpenAI spec allows max 16 key-value pairs - // We're not enforcing this limit at the database level, but documenting it -} - -#[tokio::test] -async fn test_create_and_clone_conversation_return_root_response_id() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create conversation: response metadata must include root_response_id for first-turn parallel responses - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ "metadata": {} })) - .await; - assert_eq!(create_response.status_code(), 201); - let created = create_response.json::(); - let meta = created.metadata.as_object().unwrap(); - let root_id = meta - .get("root_response_id") - .and_then(|v| v.as_str()) - .expect("create conversation response must include metadata.root_response_id"); - assert!( - root_id.starts_with("resp_"), - "root_response_id must be a response ID (resp_*), got {root_id}" - ); - println!( - "✅ Create conversation returns root_response_id: {}", - root_id - ); - - // Clone conversation: response metadata must include root_response_id (cloned conversation has its own root) - let clone_response = server - .post(format!("/v1/conversations/{}/clone", created.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_response.status_code(), 201); - let cloned = clone_response.json::(); - let cloned_meta = cloned.metadata.as_object().unwrap(); - let cloned_root_id = cloned_meta - .get("root_response_id") - .and_then(|v| v.as_str()) - .expect("clone conversation response must include metadata.root_response_id"); - assert!( - cloned_root_id.starts_with("resp_"), - "cloned root_response_id must be resp_*, got {cloned_root_id}" - ); - assert_ne!( - cloned_root_id, root_id, - "cloned conversation must have a different root_response_id than the original" - ); - println!( - "✅ Clone conversation returns root_response_id: {}", - cloned_root_id - ); -} - -#[tokio::test] -async fn test_conversation_operations_with_invalid_id_format() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - let invalid_id = "not-a-valid-uuid"; - - // Test: All operations with invalid ID format should return 400 - let pin_response = server - .post(format!("/v1/conversations/{invalid_id}/pin").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(pin_response.status_code(), 400); - - let archive_response = server - .post(format!("/v1/conversations/{invalid_id}/archive").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(archive_response.status_code(), 400); - - let clone_response = server - .post(format!("/v1/conversations/{invalid_id}/clone").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(clone_response.status_code(), 400); - - let delete_response = server - .delete(format!("/v1/conversations/{invalid_id}").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(delete_response.status_code(), 400); - - println!("✅ All operations with invalid ID format return 400"); -} - -#[tokio::test] -async fn test_conversation_unauthorized_access() { - let server = setup_test_server().await; - let (api_key1, _) = create_org_and_api_key(&server).await; - let (api_key2, _) = create_org_and_api_key(&server).await; - - // Create conversation with first API key - let conversation = create_conversation(&server, api_key1.clone()).await; - println!("Created conversation with API key 1: {}", conversation.id); - - // Test: Try to pin conversation with different API key (different workspace) - let pin_response = server - .post(format!("/v1/conversations/{}/pin", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key2}")) - .await; - assert_eq!(pin_response.status_code(), 404); // Should not find conversation in different workspace - println!("✅ Cross-workspace pin attempt returns 404"); - - // Test: Try to clone conversation with different API key - let clone_response = server - .post(format!("/v1/conversations/{}/clone", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key2}")) - .await; - assert_eq!(clone_response.status_code(), 404); - println!("✅ Cross-workspace clone attempt returns 404"); - - // Test: Try to delete conversation with different API key - let delete_response = server - .delete(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key2}")) - .await; - assert_eq!(delete_response.status_code(), 404); - println!("✅ Cross-workspace delete attempt returns 404"); - - // Verify conversation still exists with original API key - let get_response = server - .get(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key1}")) - .await; - assert_eq!(get_response.status_code(), 200); - println!("✅ Original conversation still accessible with correct API key"); -} - -#[tokio::test] -async fn test_conversation_items_include_model() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Use a specific model - let model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"; - - // Create a response with the specific model - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "input": "What is Rust?", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": model_name, - "conversation": conversation.id - })) - .await; - assert_eq!(response.status_code(), 200); - let response_obj = response.json::(); - println!("Created response: {}", response_obj.id); - - // List conversation items - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items_list = items_response.json::(); - - println!("Retrieved {} conversation items", items_list.data.len()); - - // Verify each item has the model field populated - for item in &items_list.data { - match item { - api::models::ConversationItem::Message { - id, model, role, .. - } => { - println!(" Message item {id}: role={role}, model={model}"); - - // Verify model field is populated and matches the model used for the response - assert!(!model.is_empty(), "Model field should not be empty"); - assert_eq!( - model, model_name, - "Model field should match the model used for the response" - ); - } - api::models::ConversationItem::ToolCall { id, model, .. } => { - println!(" ToolCall item {id}: model={model}"); - assert!(!model.is_empty(), "Model field should not be empty"); - assert_eq!( - model, model_name, - "Model field should match the model used for the response" - ); - } - api::models::ConversationItem::WebSearchCall { id, model, .. } => { - println!(" WebSearchCall item {id}: model={model}"); - assert!(!model.is_empty(), "Model field should not be empty"); - assert_eq!( - model, model_name, - "Model field should match the model used for the response" - ); - } - api::models::ConversationItem::Reasoning { id, model, .. } => { - println!(" Reasoning item {id}: model={model}"); - assert!(!model.is_empty(), "Model field should not be empty"); - assert_eq!( - model, model_name, - "Model field should match the model used for the response" - ); - } - api::models::ConversationItem::FunctionCall { id, model, .. } => { - println!(" FunctionCall item {id}: model={model}"); - assert!(!model.is_empty(), "Model field should not be empty"); - assert_eq!( - model, model_name, - "Model field should match the model used for the response" - ); - } - // MCP items don't have model field in the API model - api::models::ConversationItem::McpListTools { id, .. } => { - println!(" McpListTools item {id}"); - assert!(!id.is_empty(), "McpListTools id should not be empty"); - } - api::models::ConversationItem::McpCall { id, .. } => { - println!(" McpCall item {id}"); - assert!(!id.is_empty(), "McpCall id should not be empty"); - } - api::models::ConversationItem::McpApprovalRequest { id, .. } => { - println!(" McpApprovalRequest item {id}"); - assert!(!id.is_empty(), "McpApprovalRequest id should not be empty"); - } - api::models::ConversationItem::FunctionCallOutput { id, .. } => { - println!(" FunctionCallOutput item {id}"); - assert!(!id.is_empty(), "FunctionCallOutput id should not be empty"); - } - } - } - - println!("✅ All conversation items include the model field"); - println!("✅ Model field matches the model used for the response: {model_name}"); -} - -#[tokio::test] -async fn test_conversation_items_model_with_streaming() { - let server = setup_test_server().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Use a specific model - let model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"; - - // Create a streaming response with the specific model - let (_, response_obj) = create_response_stream( - &server, - conversation.id.clone(), - model_name.to_string(), - "What is Rust?".to_string(), - 100, - api_key.clone(), - ) - .await; - - println!("Created streaming response: {}", response_obj.id); - - // List conversation items - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items_list = items_response.json::(); - - println!( - "Retrieved {} conversation items from streaming response", - items_list.data.len() - ); - - // Verify each item has the model field populated - let mut found_user_message = false; - let mut found_assistant_message = false; - - for item in &items_list.data { - if let api::models::ConversationItem::Message { - id, model, role, .. - } = item - { - println!(" Message item {id}: role={role}, model={model}"); - - // Verify model field is populated and matches the model used for the response - assert!(!model.is_empty(), "Model field should not be empty"); - assert_eq!( - model, model_name, - "Model field should match the model used for the response" - ); - - if role == "user" { - found_user_message = true; - } else if role == "assistant" { - found_assistant_message = true; - } - } - } - - // Verify we found both user and assistant messages - assert!( - found_user_message, - "Should have found a user message with model field" - ); - assert!( - found_assistant_message, - "Should have found an assistant message with model field" - ); - - println!("✅ All conversation items from streaming response include the model field"); - println!("✅ Model field matches the model used for the response: {model_name}"); -} - -#[tokio::test] -async fn test_backfilled_items_include_model() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Use a specific model for backfilling - let model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"; - - // Backfill some conversation items (this creates items directly without a response) - let create_items_response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [ - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "Hello!"}] - }, - { - "type": "message", - "role": "assistant", - "content": [{"type": "input_text", "text": "Hi there!"}] - } - ] - })) - .await; - - assert_eq!(create_items_response.status_code(), 200); - let items_response = create_items_response.json::(); - assert_eq!(items_response.data.len(), 2); - - // Now create a response in the same conversation with a specific model - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "input": "What is Rust?", - "temperature": 0.7, - "max_output_tokens": 100, - "stream": false, - "model": model_name, - "conversation": conversation.id - })) - .await; - - assert_eq!(response.status_code(), 200); - - // List all conversation items - let items_list_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_list_response.status_code(), 200); - let items_list = items_list_response.json::(); - - println!( - "Retrieved {} total conversation items (backfilled + response)", - items_list.data.len() - ); - - // Verify all items have the model field populated - for item in &items_list.data { - if let api::models::ConversationItem::Message { - id, model, role, .. - } = item - { - println!(" Message item {id}: role={role}, model={model}"); - - // Verify model field is populated - // Note: Backfilled items get their model from the response they're associated with - // All items in this test should have the same model since they're all in the same response chain - assert!( - !model.is_empty(), - "Model field should not be empty for item {id}" - ); - } - } - - println!("✅ All conversation items (including backfilled) include the model field"); -} - -#[tokio::test] -async fn test_batch_get_conversations() { - let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - // Create 3 conversations - let conv1 = create_conversation(&server, api_key.clone()).await; - let conv2 = create_conversation(&server, api_key.clone()).await; - let conv3 = create_conversation(&server, api_key.clone()).await; - - println!( - "✅ Created 3 conversations: {}, {}, {}", - conv1.id, conv2.id, conv3.id - ); - - // Create 2 fake conversation IDs that don't exist (using hyphenated UUID format for consistency) - let fake_conv1_id = "conv_00000000-0000-0000-0000-000000000000"; - let fake_conv2_id = "conv_11111111-1111-1111-1111-111111111111"; - - println!("📝 Using 2 missing conversation IDs: {fake_conv1_id}, {fake_conv2_id}"); - - // Batch get 5 conversations (3 real, 2 missing) - let batch_request = serde_json::json!({ - "ids": [ - conv1.id.clone(), - conv2.id.clone(), - conv3.id.clone(), - fake_conv1_id, - fake_conv2_id, - ] - }); - - let response = server - .post("/v1/conversations/batch") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&batch_request) - .await; - - println!("📡 Batch request status: {}", response.status_code()); - assert_eq!( - response.status_code(), - 200, - "Expected 200 OK, got: {}", - response.status_code() - ); - - let batch_response = response.json::(); - - println!("✅ Response parsed successfully"); - - // Verify response structure - assert_eq!(batch_response.object, "list", "object should be 'list'"); - println!("✅ Response object type: {}", batch_response.object); - - // Verify we got 3 conversations in data - assert_eq!( - batch_response.data.len(), - 3, - "Expected 3 conversations in data, got {}", - batch_response.data.len() - ); - println!( - "✅ Found {} conversations in data (expected 3)", - batch_response.data.len() - ); - - // Verify we got 2 missing IDs - assert_eq!( - batch_response.missing_ids.len(), - 2, - "Expected 2 missing IDs, got {}", - batch_response.missing_ids.len() - ); - println!( - "✅ Found {} missing IDs (expected 2)", - batch_response.missing_ids.len() - ); - - // Verify the found conversations match what we created - let found_ids: std::collections::HashSet = - batch_response.data.iter().map(|c| c.id.clone()).collect(); - - assert!( - found_ids.contains(&conv1.id), - "conv1 ({}) should be in results", - conv1.id - ); - assert!( - found_ids.contains(&conv2.id), - "conv2 ({}) should be in results", - conv2.id - ); - assert!( - found_ids.contains(&conv3.id), - "conv3 ({}) should be in results", - conv3.id - ); - println!( - "✅ All 3 created conversations are in the results: {}, {}, {}", - conv1.id, conv2.id, conv3.id - ); - - // Verify ordering: returned conversations should match the order of requested IDs - // Expected order in request: conv1, conv2, conv3 (then 2 missing) - // So returned conversations should be in that same order - assert_eq!( - batch_response.data[0].id, conv1.id, - "First returned conversation should be conv1 (requested first)" - ); - assert_eq!( - batch_response.data[1].id, conv2.id, - "Second returned conversation should be conv2 (requested second)" - ); - assert_eq!( - batch_response.data[2].id, conv3.id, - "Third returned conversation should be conv3 (requested third)" - ); - println!("✅ Returned conversations are in the same order as requested"); - - // Verify the missing IDs are correct and returned in original format - let missing_ids_set: std::collections::HashSet = - batch_response.missing_ids.iter().cloned().collect(); - - assert!( - missing_ids_set.contains(fake_conv1_id), - "fake_conv1 ({fake_conv1_id}) should be in missing_ids, got: {:?}", - batch_response.missing_ids - ); - assert!( - missing_ids_set.contains(fake_conv2_id), - "fake_conv2 ({fake_conv2_id}) should be in missing_ids, got: {:?}", - batch_response.missing_ids - ); - println!("✅ Both missing IDs are correctly listed in original format: {fake_conv1_id}, {fake_conv2_id}"); - - // Verify missing_ids ordering is preserved from request - // Expected order in request: fake_conv1_id, fake_conv2_id (after the 3 real ones) - assert_eq!( - batch_response.missing_ids[0], fake_conv1_id, - "First missing ID should be fake_conv1 (requested 4th)" - ); - assert_eq!( - batch_response.missing_ids[1], fake_conv2_id, - "Second missing ID should be fake_conv2 (requested 5th)" - ); - println!("✅ Missing IDs are in the same order as requested and in original format"); - - // Verify each conversation object has required fields - for (idx, conv) in batch_response.data.iter().enumerate() { - assert!( - !conv.id.is_empty(), - "Conversation {idx} should have non-empty id" - ); - assert!( - !conv.object.is_empty(), - "Conversation {idx} should have non-empty object" - ); - assert!( - conv.created_at != 0, - "Conversation {idx} should have non-zero created_at" - ); - println!( - "✅ Conversation {idx}: id={}, object={}, created_at={}", - conv.id, conv.object, conv.created_at + let error = response.json::(); + assert_eq!(error.error.r#type, "gone"); + assert_eq!( + error.error.code.as_deref(), + Some("conversation_api_retired") ); - } - - println!("✅ Batch conversation retrieval test passed!"); -} - -#[tokio::test] -async fn test_conversation_title_strips_thinking_tags() { - use inference_providers::mock::ResponseTemplate; - - let (server, _pool, mock_provider, _db) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - mock_provider - .set_default_response(ResponseTemplate::new( - "Let me analyze this message to generate a title...Test Conversation Title", - )) - .await; - - let response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "test-conv-title-strip", - "description": "Testing title generation with thinking model" - })) - .await; - assert_eq!(response.status_code(), 201); - let conversation = response.json::(); - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Hello, this is a test message for title generation", - "temperature": 0.7, - "max_output_tokens": 50, - "stream": true, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507" - })) - .await; - - assert_eq!(response.status_code(), 200); - - let response_text = response.text(); - let mut title_from_event: Option = None; - - for line_chunk in response_text.split("\n\n") { - if line_chunk.trim().is_empty() { - continue; - } - - let mut event_type = ""; - let mut event_data = ""; - - for line in line_chunk.lines() { - if let Some(event_name) = line.strip_prefix("event: ") { - event_type = event_name; - } else if let Some(data) = line.strip_prefix("data: ") { - event_data = data; - } - } - - if event_type == "conversation.title.updated" && !event_data.is_empty() { - if let Ok(event_json) = serde_json::from_str::(event_data) { - if let Some(title) = event_json - .get("conversation_title") - .and_then(|v| v.as_str()) - { - title_from_event = Some(title.to_string()); - } - } - } - } - - let title = match title_from_event { - Some(t) => t, - None => { - let conv_response = server - .get(format!("/v1/conversations/{}", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(conv_response.status_code(), 200); - let updated_conv = conv_response.json::(); - updated_conv - .metadata - .get("title") - .and_then(|v| v.as_str()) - .expect("Expected title in event or metadata") - .to_string() - } - }; - - assert!( - !title.contains(""), - "Title should not contain tags, got: {title}" - ); - assert!( - !title.contains(""), - "Title should not contain tags, got: {title}" - ); - println!("✅ Title stripped thinking tags: {title}"); -} - -#[tokio::test] -async fn test_chat_completions_with_json_schema() { - use crate::common::mock_prompts; - use inference_providers::mock::{RequestMatcher, ResponseTemplate}; - - let (server, _pool, mock, _db) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - // Configure mock to match exact prompt and return structured JSON - // Chat completions API sends messages directly without language instruction - let user_message = "Generate a user profile"; - let expected_prompt = mock_prompts::build_simple_prompt(user_message); - let expected_json = r#"{"name": "Alice Johnson", "age": 28, "email": "alice@example.com"}"#; - - mock.when(RequestMatcher::ExactPrompt(expected_prompt)) - .respond_with(ResponseTemplate::new(expected_json)) - .await; - - // Make a chat completion request with response_format - let response = server - .post("/v1/chat/completions") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "messages": [ - { - "role": "user", - "content": user_message - } - ], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "user_profile", - "schema": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"}, - "email": {"type": "string"} - }, - "required": ["name", "age", "email"] - }, - "strict": true - } - } - })) - .await; - - if response.status_code() != 200 { - let error = response.text(); - println!("Error response: {}", error); - } - assert_eq!(response.status_code(), 200); - let completion = response.json::(); - - // Verify the response contains the expected JSON - let content = completion["choices"][0]["message"]["content"] - .as_str() - .expect("Expected content in response"); - - assert_eq!(content, expected_json); - - // Verify it's valid JSON matching the schema - let json_obj: serde_json::Value = - serde_json::from_str(content).expect("Content should be valid JSON"); - assert_eq!(json_obj["name"], "Alice Johnson"); - assert_eq!(json_obj["age"], 28); - assert_eq!(json_obj["email"], "alice@example.com"); - - println!("✅ Chat completions with JSON schema returned structured output"); -} - -#[tokio::test] -async fn test_responses_api_with_json_schema() { - use crate::common::mock_prompts; - use inference_providers::mock::{RequestMatcher, ResponseTemplate}; - - let (server, _pool, mock, _db) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - // Configure mock to match exact prompt and return structured JSON - let user_message = "Generate a book description"; - let expected_prompt = mock_prompts::build_prompt(user_message); - let expected_json = r#"{"title": "The Great Adventure", "author": "John Smith", "year": 2024, "genre": "Fiction"}"#; - - mock.when(RequestMatcher::ExactPrompt(expected_prompt)) - .respond_with(ResponseTemplate::new(expected_json)) - .await; - - // Create a conversation - let conversation = create_conversation(&server, api_key.clone()).await; - - // Make a response request with text.format.json_schema - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": user_message, - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "max_output_tokens": 100, - "stream": false, - "text": { - "format": { - "type": "json_schema", - "json_schema": { - "name": "book", - "schema": { - "type": "object", - "properties": { - "title": {"type": "string"}, - "author": {"type": "string"}, - "year": {"type": "integer"}, - "genre": {"type": "string"} - }, - "required": ["title", "author", "year", "genre"] - }, - "strict": true - } - } - } - })) - .await; - - assert_eq!(response.status_code(), 200); - let response_obj = response.json::(); - - // Verify the response contains structured output - assert!(!response_obj.output.is_empty()); - - if let ResponseOutputItem::Message { content, .. } = &response_obj.output[0] { - assert!(!content.is_empty()); - - if let ResponseOutputContent::OutputText { text, .. } = &content[0] { - // Verify it's valid JSON matching the schema - let json_obj: serde_json::Value = - serde_json::from_str(text).expect("Content should be valid JSON"); - assert_eq!(json_obj["title"], "The Great Adventure"); - assert_eq!(json_obj["author"], "John Smith"); - assert_eq!(json_obj["year"], 2024); - assert_eq!(json_obj["genre"], "Fiction"); - - println!("✅ Responses API with JSON schema returned structured output"); - } else { - panic!("Expected OutputText content"); - } - } else { - panic!("Expected Message output item"); + assert!(error.error.message.contains("POST /v1/responses")); } } diff --git a/docs/local-development.md b/docs/local-development.md index 646fe5a37..6a5c2f102 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -66,7 +66,7 @@ The API has two mutually-exclusive auth methods: - **Session auth** (cookies / `Authorization: Bearer rt_…`) for the management plane: organizations, workspaces, users, API keys. - **API key auth** (`Authorization: Bearer sk-…`) for the data plane: - chat completions, responses, conversations, attestation. + chat completions, responses, and attestation. ### Mock session auth (no real OAuth) @@ -273,7 +273,6 @@ Provider refresh runs every 300s by default | `GET /v1/models` | public | OpenAI-compatible model catalog with pricing metadata | | `POST /v1/chat/completions` | API key | OpenAI-compatible. Add `"stream": true` for SSE | | `POST /v1/responses` | API key | Platform-specific event-streamed responses | -| `POST /v1/conversations` | API key | Conversation lifecycle | | `GET /v1/attestation/report` | API key | TEE attestation (503 outside a CVM unless `DEV=true` in debug builds) | | `GET /v1/attestation/ita-token` | public | Intel Trust Authority JWT wrapper (requires ITA env vars) | | `GET /v1/signature/{chat_id}` | API key | Per-completion signature lookup | From 014e3f691480b6eac6ff945b29d3bf74eca12e25 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:39:47 +0800 Subject: [PATCH 03/31] feat: make responses API stateless (cherry picked from commit 2f3aa2232649aaee5b440b4c17b78db9e1e1df58) --- crates/api/src/lib.rs | 34 +- crates/api/src/openapi.rs | 6 +- crates/api/src/routes/responses.rs | 577 ++++----------------- crates/services/src/responses/mod.rs | 1 + crates/services/src/responses/models.rs | 257 +++++++++ crates/services/src/responses/service.rs | 36 +- crates/services/src/responses/transient.rs | 552 ++++++++++++++++++++ docs/local-development.md | 2 +- 8 files changed, 972 insertions(+), 493 deletions(-) create mode 100644 crates/services/src/responses/transient.rs diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index e94d937b8..7ae315c44 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1650,14 +1650,13 @@ pub fn build_completion_routes( /// Build response routes with auth pub fn build_response_routes( response_service: Arc, - attestation_service: Arc, + _attestation_service: Arc, auth_state_middleware: &AuthState, usage_state: middleware::UsageState, rate_limit_state: middleware::RateLimitState, ) -> Router { let route_state = responses::ResponseRouteState { response_service: response_service.clone(), - attestation_service: attestation_service.clone(), }; let inference_routes = Router::new() @@ -1677,19 +1676,20 @@ pub fn build_response_routes( )) .layer(from_fn(middleware::body_hash_middleware)); - let other_routes = Router::new() - .route("/responses/{response_id}", get(responses::get_response)) + let retired_history_routes = Router::new() + // Keep retired response-history paths explicit so authenticated clients + // receive a useful migration signal instead of an ambiguous 404/405. .route( "/responses/{response_id}", - axum::routing::delete(responses::delete_response), + any(responses::response_history_gone), ) .route( "/responses/{response_id}/cancel", - post(responses::cancel_response), + any(responses::response_history_gone), ) .route( "/responses/{response_id}/input_items", - get(responses::list_input_items), + any(responses::response_history_gone), ) .with_state(route_state) .layer(from_fn_with_state( @@ -1701,7 +1701,9 @@ pub fn build_response_routes( middleware::auth::auth_middleware_with_workspace_context, )); - Router::new().merge(inference_routes).merge(other_routes) + Router::new() + .merge(inference_routes) + .merge(retired_history_routes) } /// Build explicit not-implemented handlers for recognized OpenAI-compatible @@ -2583,6 +2585,22 @@ mod tests { ); } + #[test] + fn test_openapi_omits_retired_response_history_paths() { + let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + for path in [ + "/v1/responses/{response_id}", + "/v1/responses/{response_id}/cancel", + "/v1/responses/{response_id}/input_items", + ] { + assert!( + spec["paths"].get(path).is_none(), + "retired response-history path {path} must not be in OpenAPI" + ); + } + } + #[test] fn test_openapi_signature_requires_api_key() { // nearai/infra#193: /v1/signature/{chat_id} stays API-key-protected. diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 5a0d740ff..2b473de2a 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -25,7 +25,7 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Responses", description = "Response handling and streaming"), + (name = "Responses", description = "Single-turn, stateless response inference. Response and item content are not persisted; billing, rate limiting, and operations retain only the minimum non-content metadata."), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), @@ -58,10 +58,6 @@ use utoipa::{Modify, OpenApi}; crate::routes::models::get_model_by_name, // Response endpoints crate::routes::responses::create_response, - crate::routes::responses::get_response, - crate::routes::responses::delete_response, - crate::routes::responses::cancel_response, - crate::routes::responses::list_input_items, // Organization endpoints crate::routes::organizations::list_organizations, crate::routes::organizations::create_organization, diff --git a/crates/api/src/routes/responses.rs b/crates/api/src/routes/responses.rs index 068a4bea6..b9336e058 100644 --- a/crates/api/src/routes/responses.rs +++ b/crates/api/src/routes/responses.rs @@ -1,95 +1,23 @@ use crate::{ middleware::{auth::AuthenticatedApiKey, RequestBodyHash, RequestCorrelation}, - models::{ErrorResponse, ResponseInputItemList}, - routes::common::{HEADER_SHOULD_RETRY, SHOULD_RETRY_FALSE}, + models::ErrorResponse, routes::extractors::OpenAiJson, }; use axum::{ body::Body, - extract::{Extension, Path, Query, State}, - http::{header, HeaderMap, Response, StatusCode}, + extract::{Extension, State}, + http::{header, HeaderMap, HeaderValue, Response, StatusCode}, response::{IntoResponse, Json as ResponseJson}, }; use bytes::Bytes; use futures::stream::StreamExt; -use serde::Deserialize; -use services::attestation::ports::AttestationServiceTrait; use services::responses::errors::ResponseError as ServiceResponseError; use services::responses::models::*; use services::responses::ports::ResponseServiceTrait; use services::responses::service::ResponseServiceImpl; -use sha2::{Digest, Sha256}; use std::convert::Infallible; use std::sync::Arc; use tracing::debug; -use uuid::Uuid; - -type NotImplementedErrorResponse = ( - StatusCode, - [(&'static str, &'static str); 1], - ResponseJson, -); - -fn not_implemented_error(message: impl Into) -> NotImplementedErrorResponse { - ( - StatusCode::NOT_IMPLEMENTED, - [(HEADER_SHOULD_RETRY, SHOULD_RETRY_FALSE)], - ResponseJson(ErrorResponse::new( - message.into(), - "not_implemented".to_string(), - )), - ) -} - -#[cfg(test)] -mod tests { - use super::not_implemented_error; - use crate::routes::common::{HEADER_SHOULD_RETRY, SHOULD_RETRY_FALSE}; - use axum::{http::StatusCode, response::IntoResponse}; - - #[test] - fn not_implemented_error_disables_sdk_retries() { - let response = not_implemented_error("Permanent error").into_response(); - - assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - assert_eq!( - response - .headers() - .get(HEADER_SHOULD_RETRY) - .and_then(|value| value.to_str().ok()), - Some(SHOULD_RETRY_FALSE) - ); - } -} - -// Helper function to convert service ResponseContentItem to API ResponseContentPart (input-only) -fn convert_to_input_part( - item: services::responses::models::ResponseContentItem, -) -> Option { - match item { - ResponseContentItem::InputText { text } => { - Some(crate::models::ResponseContentPart::InputText { text }) - } - ResponseContentItem::InputImage { image_url, detail } => { - Some(crate::models::ResponseContentPart::InputImage { image_url, detail }) - } - ResponseContentItem::InputFile { file_id, detail } => { - Some(crate::models::ResponseContentPart::InputFile { file_id, detail }) - } - ResponseContentItem::OutputText { text, .. } => { - // Backward compatibility: check for legacy file reference - match crate::routes::common::parse_legacy_file_reference(&text) { - Ok(Some(file_id)) => Some(crate::models::ResponseContentPart::InputFile { - file_id, - detail: None, - }), - Ok(None) | Err(_) => Some(crate::models::ResponseContentPart::InputText { text }), - } - } - ResponseContentItem::ToolCalls { .. } => None, - ResponseContentItem::OutputImage { .. } => None, - } -} // Helper functions for error mapping fn map_response_error_to_status(error: &ServiceResponseError) -> StatusCode { @@ -133,11 +61,11 @@ fn error_response_from_response_event( response } -/// Compute SHA256 hash of data -fn compute_sha256(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - hex::encode(hasher.finalize()) +fn with_no_store_cache_header(mut response: axum::response::Response) -> axum::response::Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response } impl From for ErrorResponse { @@ -226,12 +154,30 @@ impl From for ErrorResponse { #[derive(Clone)] pub struct ResponseRouteState { pub response_service: Arc, - pub attestation_service: Arc, +} + +/// Return an explicit migration response for the retired response-history API. +/// +/// The route is mounted behind the standard API-key middleware. Keeping it +/// separate from the create endpoint makes it clear that only a new, single +/// stateless request is supported. +pub async fn response_history_gone() -> axum::response::Response { + with_no_store_cache_header( + ( + StatusCode::GONE, + ResponseJson(ErrorResponse::new( + "Response history is unavailable because the Responses API is stateless." + .to_string(), + "gone".to_string(), + )), + ) + .into_response(), + ) } /// Create response /// -/// Generate an AI response for a conversation with tool calling and streaming support. +/// Generate a single-turn, stateless AI response with optional streaming. #[utoipa::path( post, path = "/v1/responses", @@ -257,7 +203,6 @@ pub async fn create_response( OpenAiJson(mut request): OpenAiJson, ) -> axum::response::Response { let service = state.response_service.clone(); - let attestation_service = state.attestation_service.clone(); debug!( "Create response request from api key: {:?}", api_key.api_key.id @@ -265,20 +210,35 @@ pub async fn create_response( // Validate the request if let Err(error) = request.validate() { - return ( - StatusCode::BAD_REQUEST, - ResponseJson(ErrorResponse::new( - error, - "invalid_request_error".to_string(), - )), - ) - .into_response(); + return with_no_store_cache_header( + ( + StatusCode::BAD_REQUEST, + ResponseJson(ErrorResponse::new( + error, + "invalid_request_error".to_string(), + )), + ) + .into_response(), + ); + } + + if let Err(error) = request.validate_stateless() { + return with_no_store_cache_header( + ( + StatusCode::BAD_REQUEST, + ResponseJson(ErrorResponse::new( + error, + "invalid_request_error".to_string(), + )), + ) + .into_response(), + ); } // Extract and validate encryption headers if present let encryption_headers = match crate::routes::common::validate_encryption_headers(&headers) { Ok(headers) => headers, - Err(err) => return err.into_response(), + Err(err) => return with_no_store_cache_header(err.into_response()), }; let signing_algo = encryption_headers.signing_algo; @@ -289,19 +249,22 @@ pub async fn create_response( // Encryption requires streaming mode because encrypted chunks from vLLM are independently // encrypted and cannot be concatenated. Non-streaming mode would produce corrupted data. if signing_algo.is_some() && client_pub_key.is_some() && request.stream != Some(true) { - return ( - StatusCode::BAD_REQUEST, - ResponseJson(ErrorResponse::new( - "Non-streaming mode is not supported with encryption. Use stream=true.".to_string(), - "encryption_requires_streaming".to_string(), - )), - ) - .into_response(); + return with_no_store_cache_header( + ( + StatusCode::BAD_REQUEST, + ResponseJson(ErrorResponse::new( + "Non-streaming mode is not supported with encryption. Use stream=true." + .to_string(), + "encryption_requires_streaming".to_string(), + )), + ) + .into_response(), + ); } // Set defaults for internal fields request.max_tool_calls = request.max_tool_calls.or(Some(10)); - request.store = request.store.or(Some(true)); + request.store = Some(false); request.background = request.background.or(Some(false)); request.reasoning = request .reasoning @@ -338,88 +301,23 @@ pub async fn create_response( Ok(stream) => { tracing::debug!( user_id = %api_key.api_key.created_by_user_id.0, - "Successfully created streaming response, returning SSE stream with signature accumulation" + "Successfully created streaming response" ); - // Shared state for accumulating bytes and tracking response_id - let accumulated_bytes = Arc::new(tokio::sync::Mutex::new(Vec::new())); - let response_id_state = Arc::new(tokio::sync::Mutex::new(None::)); - let request_hash = body_hash.hash.clone(); - - // Clone for closures - let accumulated_clone = accumulated_bytes.clone(); - let response_id_clone = response_id_state.clone(); - let attestation_clone = attestation_service.clone(); - - // Format events as SSE bytes and accumulate them - let byte_stream = stream.then(move |event| { - let accumulated_inner = accumulated_clone.clone(); - let response_id_inner = response_id_clone.clone(); - let attestation_inner = attestation_clone.clone(); - let request_hash_inner = request_hash.clone(); - async move { - // Extract response_id from response.created event - if event.event_type == "response.created" { - if let Some(ref response) = event.response { - let mut rid = response_id_inner.lock().await; - if rid.is_none() { - *rid = Some(response.id.clone()); - tracing::debug!("Extracted response_id: {}", response.id); - } - } - } - - // Format as SSE: "event: {type}\ndata: {json}\n\n" - let json = serde_json::to_string(&event) - .expect("event serialization failed"); - let sse_bytes = format!("event: {}\ndata: {}\n\n", event.event_type, json); - let bytes = Bytes::from(sse_bytes); - - // Accumulate bytes synchronously - this ensures all bytes are captured - // before the stream chunk is yielded to the client - accumulated_inner.lock().await.extend_from_slice(&bytes); - - // Check if stream is completing - store signature - if event.event_type == "response.completed" { - // At this point, all bytes have been accumulated synchronously - // Now we can safely compute the hash and store the signature - let bytes_accumulated = accumulated_inner.lock().await.clone(); - let response_hash = compute_sha256(&bytes_accumulated); - if let Some(rid) = response_id_inner.lock().await.as_ref() { - let rid = rid.clone(); - let req_hash = request_hash_inner.clone(); - let attest = attestation_inner.clone(); - tracing::debug!( - "Storing signature for response_id: {}, request_hash: {}, response_hash: {}", - rid, req_hash, response_hash - ); - - // Spawn task to store signature asynchronously (doesn't block stream) - // but we've already computed the hash with complete data - tokio::spawn(async move { - // Store both ECDSA and ED25519 signatures - if let Err(e) = attest.store_response_signature( - &rid, - req_hash.clone(), - response_hash.clone(), - ).await { - tracing::error!("Failed to store response signature: {}", e); - } else { - tracing::debug!("Successfully stored signature for response_id: {}", rid); - } - }); - } - } - - Ok::(bytes) - } + // Format events as SSE without retaining the stream payload or + // writing a response attestation. A no-store response has no + // durable response record to associate with such data. + let byte_stream = stream.map(|event| { + let json = serde_json::to_string(&event).expect("event serialization failed"); + let sse_bytes = format!("event: {}\ndata: {}\n\n", event.event_type, json); + Ok::(Bytes::from(sse_bytes)) }); // Return as raw byte stream with SSE headers Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream") - .header(header::CACHE_CONTROL, "no-cache") + .header(header::CACHE_CONTROL, "no-store") .header(header::CONNECTION, "keep-alive") .body(Body::from_stream(byte_stream)) .unwrap() @@ -432,7 +330,9 @@ pub async fn create_response( "Failed to create streaming response" ); let status_code = map_response_error_to_status(&error); - (status_code, ResponseJson::(error.into())).into_response() + with_no_store_cache_header( + (status_code, ResponseJson::(error.into())).into_response(), + ) } } } else { @@ -582,7 +482,9 @@ pub async fn create_response( if let Some(error) = failed_error { let status_code = status_code_from_response_event(failed_status_code); let error_response = error_response_from_response_event(error); - return (status_code, ResponseJson(error_response)).into_response(); + return with_no_store_cache_header( + (status_code, ResponseJson(error_response)).into_response(), + ); } } @@ -594,26 +496,15 @@ pub async fn create_response( // Fallback: Build response from collected data (for compatibility) // Trim accumulated content to remove leading/trailing whitespace let trimmed_content = content.trim().to_string(); - let resp_id = - response_id.unwrap_or_else(|| format!("resp_{}", Uuid::new_v4().simple())); + let resp_id = response_id + .unwrap_or_else(|| format!("resp_{}", uuid::Uuid::new_v4().simple())); ResponseObject { id: resp_id.clone(), object: "response".to_string(), created_at: chrono::Utc::now().timestamp(), status, - background: request.background.unwrap_or(false), - conversation: request.conversation.as_ref().map(|conv_ref| { - let id = match conv_ref { - services::responses::models::ConversationReference::Id(id) => { - id.clone() - } - services::responses::models::ConversationReference::Object { - id, - .. - } => id.clone(), - }; - services::responses::models::ConversationResponseReference { id } - }), + background: false, + conversation: None, error: None, incomplete_details: None, instructions: request.instructions, @@ -621,9 +512,9 @@ pub async fn create_response( max_tool_calls: request.max_tool_calls, model: request.model.clone(), output: vec![ResponseOutputItem::Message { - id: format!("msg_{}", Uuid::new_v4().simple()), + id: format!("msg_{}", uuid::Uuid::new_v4().simple()), response_id: resp_id.clone(), - previous_response_id: request.previous_response_id.clone(), + previous_response_id: None, next_response_ids: vec![], created_at: chrono::Utc::now().timestamp(), status: ResponseItemStatus::Completed, @@ -637,14 +528,14 @@ pub async fn create_response( metadata: None, }], parallel_tool_calls: request.parallel_tool_calls.unwrap_or(false), - previous_response_id: request.previous_response_id.clone(), + previous_response_id: None, next_response_ids: vec![], prompt_cache_key: request.prompt_cache_key, prompt_cache_retention: None, reasoning: None, safety_identifier: request.safety_identifier, service_tier: "default".to_string(), - store: request.store.unwrap_or(false), + store: false, temperature: request.temperature.unwrap_or(1.0), tool_choice: ResponseToolChoiceOutput::Auto("auto".to_string()), tools: request.tools.unwrap_or_default(), @@ -662,23 +553,7 @@ pub async fn create_response( response.id, api_key.api_key.created_by_user_id.0 ); - // Store signature for non-streaming response - let response_id = response.id.clone(); - let response_json = - serde_json::to_string(&response).expect("response serialization failed"); - let response_hash = compute_sha256(response_json.as_bytes()); - - if let Err(e) = attestation_service - .store_response_signature(&response_id, body_hash.hash.clone(), response_hash) - .await - { - tracing::error!( - "Failed to store response signature for non-streaming: {}", - e - ); - } - - (StatusCode::OK, ResponseJson(response)).into_response() + with_no_store_cache_header((StatusCode::OK, ResponseJson(response)).into_response()) } Err(error) => { tracing::error!( @@ -688,262 +563,40 @@ pub async fn create_response( "Failed to create non-streaming response" ); let status_code = map_response_error_to_status(&error); - (status_code, ResponseJson::(error.into())).into_response() + with_no_store_cache_header( + (status_code, ResponseJson::(error.into())).into_response(), + ) } } } } -/// Get a response by ID -/// -/// Retrieve details of a specific response. -#[utoipa::path( - get, - path = "/v1/responses/{response_id}", - tag = "Responses", - params( - ("response_id" = String, Path, description = "Response ID") - ), - responses( - (status = 200, description = "Response details", body = ResponseObject), - (status = 401, description = "Invalid or missing API key", body = ErrorResponse), - (status = 404, description = "Response not found", body = ErrorResponse), - (status = 501, description = "Not implemented", body = ErrorResponse) - ), - security( - ("api_key" = []) - ) -)] -pub async fn get_response( - Path(_response_id): Path, - Query(_params): Query, - State(_state): State, - Extension(_api_key): Extension, -) -> Result, NotImplementedErrorResponse> { - // TODO: Implement get_response method in ResponseService - Err(not_implemented_error("Get response not yet implemented")) -} - -/// Delete a response -/// -/// Delete a specific response. -#[utoipa::path( - delete, - path = "/v1/responses/{response_id}", - tag = "Responses", - params( - ("response_id" = String, Path, description = "Response ID") - ), - responses( - (status = 200, description = "Response deleted successfully"), - (status = 401, description = "Invalid or missing API key", body = ErrorResponse), - (status = 404, description = "Response not found", body = ErrorResponse), - (status = 501, description = "Not implemented", body = ErrorResponse) - ), - security( - ("api_key" = []) - ) -)] -pub async fn delete_response( - Path(_response_id): Path, - State(_state): State, - Extension(_api_key): Extension, -) -> Result, NotImplementedErrorResponse> { - // TODO: Implement delete_response method in ResponseService - Err(not_implemented_error("Delete response not yet implemented")) -} - -/// Cancel a response (for background responses) -/// -/// Cancel an in-progress background response. -#[utoipa::path( - post, - path = "/v1/responses/{response_id}/cancel", - tag = "Responses", - params( - ("response_id" = String, Path, description = "Response ID") - ), - responses( - (status = 200, description = "Response cancelled successfully", body = ResponseObject), - (status = 401, description = "Invalid or missing API key", body = ErrorResponse), - (status = 404, description = "Response not found", body = ErrorResponse), - (status = 501, description = "Not implemented", body = ErrorResponse) - ), - security( - ("api_key" = []) - ) -)] -pub async fn cancel_response( - Path(_response_id): Path, - State(_state): State, - Extension(_api_key): Extension, -) -> Result, NotImplementedErrorResponse> { - // TODO: Implement cancel_response method in ResponseService - Err(not_implemented_error("Cancel response not yet implemented")) -} - -/// List input items for a response -/// -/// Retrieve all input items (user messages and files) for a specific response. -#[utoipa::path( - get, - path = "/v1/responses/{response_id}/input_items", - tag = "Responses", - params( - ("response_id" = String, Path, description = "Response ID") - ), - responses( - (status = 200, description = "List of input items", body = ResponseInputItemList), - (status = 400, description = "Invalid response ID", body = ErrorResponse), - (status = 401, description = "Invalid or missing API key", body = ErrorResponse), - (status = 404, description = "Response not found", body = ErrorResponse), - (status = 500, description = "Server error", body = ErrorResponse) - ), - security( - ("api_key" = []) - ) -)] -pub async fn list_input_items( - Path(response_id): Path, - Query(params): Query, - State(state): State, - Extension(auth): Extension, -) -> Result, (StatusCode, ResponseJson)> { - let service = state.response_service.clone(); - debug!( - "List input items for response {} from workspace {}", - response_id, auth.workspace.id.0 - ); - - // Parse response ID (format: "resp_{uuid}") - let response_uuid = response_id - .strip_prefix("resp_") - .unwrap_or(&response_id) - .parse::() - .map_err(|_| { - ( - StatusCode::BAD_REQUEST, - ResponseJson(ErrorResponse::new( - "Invalid response ID format".to_string(), - "invalid_request_error".to_string(), - )), - ) - })?; - - let parsed_response_id = ResponseId(response_uuid); +#[cfg(test)] +mod tests { + use super::*; - // Verify the response belongs to this workspace - match service - .response_repository - .get_by_id(parsed_response_id.clone(), auth.workspace.id.clone()) - .await - { - Ok(Some(_)) => { - // Response exists and belongs to workspace, proceed - } - Ok(None) => { - return Err(( - StatusCode::NOT_FOUND, - ResponseJson(ErrorResponse::new( - "Response not found".to_string(), - "not_found".to_string(), - )), - )); - } - Err(e) => { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - ResponseJson(ErrorResponse::new( - format!("Failed to fetch response: {e}"), - "internal_server_error".to_string(), - )), - )); - } + #[test] + fn response_results_are_marked_no_store() { + let response = with_no_store_cache_header(StatusCode::OK.into_response()); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); } - // Get all response items - let items = service - .response_items_repository - .list_by_response(parsed_response_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - ResponseJson(ErrorResponse::new( - format!("Failed to fetch response items: {e}"), - "internal_server_error".to_string(), - )), - ) - })?; - - // Filter to only user input items and convert to API format - let mut input_items: Vec = Vec::new(); - - for item in items { - if let ResponseOutputItem::Message { - role, - content, - metadata, - .. - } = item - { - if role == "user" { - // Convert service ResponseContentItem to API ResponseContentPart (input-only) - // This provides type safety - only input variants can exist here - let api_content: Vec = content - .into_iter() - .filter_map(convert_to_input_part) - .collect(); - - input_items.push(crate::models::ResponseInputItem { - role, - content: crate::models::ResponseContent::Parts(api_content), - metadata, - }); - } - } + #[tokio::test] + async fn response_history_is_explicitly_gone() { + let response = response_history_gone().await; + assert_eq!(response.status(), StatusCode::GONE); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); } - - // Apply pagination if needed (for now, return all) - let limit = params.limit.unwrap_or(100).min(1000); - let has_more = input_items.len() > limit as usize; - let input_items: Vec<_> = input_items.into_iter().take(limit as usize).collect(); - - let first_id = if input_items.is_empty() { - String::new() - } else { - "0".to_string() - }; - let last_id = if input_items.is_empty() { - String::new() - } else { - (input_items.len() - 1).to_string() - }; - - Ok(ResponseJson(ResponseInputItemList { - object: "list".to_string(), - data: input_items, - first_id, - last_id, - has_more, - })) -} - -// Query parameter structs -#[derive(Debug, Deserialize)] -pub struct GetResponseQuery { - #[serde(default)] - pub include: Vec, - #[serde(default)] - pub include_obfuscation: Option, - pub starting_after: Option, - pub stream: Option, -} - -#[derive(Debug, Deserialize)] -pub struct ListInputItemsQuery { - pub after: Option, - pub include: Option>, - pub limit: Option, - pub order: Option, // "asc" or "desc" } diff --git a/crates/services/src/responses/mod.rs b/crates/services/src/responses/mod.rs index 42043ad5e..7798553de 100644 --- a/crates/services/src/responses/mod.rs +++ b/crates/services/src/responses/mod.rs @@ -5,3 +5,4 @@ pub mod ports; pub mod service; mod service_helpers; pub mod tools; +mod transient; diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index 56335b240..09ee19216 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -1225,6 +1225,108 @@ impl CreateResponseRequest { Ok(()) } + + /// Validate that a request can be handled without platform-side response + /// or conversation persistence. + /// + /// The Responses API is intentionally limited to a single request/response + /// interaction. Clients that need a multi-turn flow must include the full + /// context in the new request instead of referring to stored server state. + pub fn validate_stateless(&self) -> Result<(), String> { + if self.store == Some(true) { + return Err("The Responses API only supports store: false.".to_string()); + } + + if self.conversation.is_some() { + return Err("The stateless Responses API does not support conversation.".to_string()); + } + + if self.previous_response_id.is_some() { + return Err( + "The stateless Responses API does not support previous_response_id.".to_string(), + ); + } + + if self.background == Some(true) { + return Err("The stateless Responses API does not support background.".to_string()); + } + + if let Some(ResponseInput::Items(items)) = &self.input { + for item in items { + match item { + ResponseInputItem::McpApprovalResponse { .. } => { + return Err( + "The stateless Responses API does not support MCP approval continuation." + .to_string(), + ); + } + ResponseInputItem::FunctionCallOutput { .. } => { + return Err( + "The stateless Responses API does not support function continuation." + .to_string(), + ); + } + ResponseInputItem::Message { + content: ResponseContent::Parts(parts), + .. + } if parts + .iter() + .any(|part| matches!(part, ResponseContentPart::InputFile { .. })) => + { + return Err( + "The stateless Responses API does not support input_file.".to_string() + ); + } + _ => {} + } + } + } + + if let Some(tools) = &self.tools { + for tool in tools { + match tool { + ResponseTool::FileSearch { .. } => { + return Err( + "The stateless Responses API does not support file_search.".to_string() + ); + } + ResponseTool::Function { .. } => { + return Err( + "The stateless Responses API does not support function tools because they require continuation." + .to_string(), + ); + } + ResponseTool::CodeInterpreter {} => { + return Err( + "The stateless Responses API does not support code_interpreter because it requires continuation." + .to_string(), + ); + } + ResponseTool::Computer {} => { + return Err( + "The stateless Responses API does not support computer because it requires continuation." + .to_string(), + ); + } + ResponseTool::Mcp { + require_approval, .. + } if !matches!( + require_approval, + McpApprovalRequirement::Simple(McpApprovalMode::Never) + ) => + { + return Err( + "The stateless Responses API does not support MCP tools that require approval." + .to_string(), + ); + } + _ => {} + } + } + } + + Ok(()) + } } impl CreateConversationRequest { @@ -1284,6 +1386,31 @@ mod tests { use super::*; use serde_json::json; + fn stateless_request() -> CreateResponseRequest { + CreateResponseRequest { + model: "gpt-4".to_string(), + input: Some(ResponseInput::Text("Hello".to_string())), + instructions: None, + conversation: None, + previous_response_id: None, + max_output_tokens: None, + max_tool_calls: None, + temperature: None, + top_p: None, + stream: None, + store: None, + background: None, + tools: None, + tool_choice: None, + parallel_tool_calls: None, + reasoning: None, + include: None, + metadata: None, + safety_identifier: None, + prompt_cache_key: None, + } + } + #[test] fn test_response_status_serializes_in_progress_with_underscore() { assert_eq!( @@ -1927,4 +2054,134 @@ mod tests { assert!(mcp_item.metadata().is_none()); } + + #[test] + fn stateless_requests_accept_omitted_or_false_store() { + let request = stateless_request(); + assert!(request.validate_stateless().is_ok()); + + let mut explicit_no_store = request; + explicit_no_store.store = Some(false); + assert!(explicit_no_store.validate_stateless().is_ok()); + } + + #[test] + fn stateless_requests_reject_persistent_response_fields() { + let mut store = stateless_request(); + store.store = Some(true); + assert!(store + .validate_stateless() + .unwrap_err() + .contains("store: false")); + + let mut conversation = stateless_request(); + conversation.conversation = Some(ConversationReference::Id("conv_test".to_string())); + assert!(conversation + .validate_stateless() + .unwrap_err() + .contains("conversation")); + + let mut previous_response = stateless_request(); + previous_response.previous_response_id = Some("resp_test".to_string()); + assert!(previous_response + .validate_stateless() + .unwrap_err() + .contains("previous_response_id")); + + let mut background = stateless_request(); + background.background = Some(true); + assert!(background + .validate_stateless() + .unwrap_err() + .contains("background")); + } + + #[test] + fn stateless_requests_reject_stateful_inputs_and_tools() { + let mut input_file = stateless_request(); + input_file.input = Some(ResponseInput::Items(vec![ResponseInputItem::Message { + role: "user".to_string(), + content: ResponseContent::Parts(vec![ResponseContentPart::InputFile { + file_id: "file_test".to_string(), + detail: None, + }]), + metadata: None, + }])); + assert!(input_file + .validate_stateless() + .unwrap_err() + .contains("input_file")); + + let mut function_continuation = stateless_request(); + function_continuation.input = Some(ResponseInput::Items(vec![ + ResponseInputItem::FunctionCallOutput { + type_: FunctionCallOutputType::FunctionCallOutput, + call_id: "call_test".to_string(), + output: "{}".to_string(), + }, + ])); + assert!(function_continuation + .validate_stateless() + .unwrap_err() + .contains("function continuation")); + + let mut mcp_approval = stateless_request(); + mcp_approval.input = Some(ResponseInput::Items(vec![ + ResponseInputItem::McpApprovalResponse { + type_: McpApprovalResponseType::McpApprovalResponse, + approval_request_id: "apr_test".to_string(), + approve: true, + }, + ])); + assert!(mcp_approval + .validate_stateless() + .unwrap_err() + .contains("MCP approval continuation")); + + let mut file_search = stateless_request(); + file_search.tools = Some(vec![ResponseTool::FileSearch {}]); + assert!(file_search + .validate_stateless() + .unwrap_err() + .contains("file_search")); + + let mut function_tool = stateless_request(); + function_tool.tools = Some(vec![ResponseTool::Function { + name: "lookup".to_string(), + description: None, + parameters: None, + }]); + assert!(function_tool + .validate_stateless() + .unwrap_err() + .contains("function tools")); + + let mut code_interpreter = stateless_request(); + code_interpreter.tools = Some(vec![ResponseTool::CodeInterpreter {}]); + assert!(code_interpreter + .validate_stateless() + .unwrap_err() + .contains("code_interpreter")); + + let mut computer = stateless_request(); + computer.tools = Some(vec![ResponseTool::Computer {}]); + assert!(computer + .validate_stateless() + .unwrap_err() + .contains("computer")); + + let mut mcp_tool = stateless_request(); + mcp_tool.tools = Some(vec![ResponseTool::Mcp { + server_label: "test".to_string(), + server_url: "https://example.com/mcp".to_string(), + server_description: None, + authorization: None, + require_approval: McpApprovalRequirement::Simple(McpApprovalMode::Always), + allowed_tools: None, + }]); + assert!(mcp_tool + .validate_stateless() + .unwrap_err() + .contains("require approval")); + } } diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index 392d5276f..75512bb16 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -12,7 +12,7 @@ use crate::conversations::ports::ConversationServiceTrait; use crate::files::FileServiceTrait; use crate::inference_provider_pool::InferenceProviderPool; use crate::responses::tools; -use crate::responses::{citation_tracker, errors, models, ports}; +use crate::responses::{citation_tracker, errors, models, ports, transient}; use tools::{ERROR_TOOL_TYPE, MAX_CONSECUTIVE_TOOL_FAILURES}; @@ -157,26 +157,26 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { use futures::channel::mpsc; use futures::SinkExt; - // Validate: function_call_output items require a previous_response_id - let has_function_outputs = match &request.input { - Some(models::ResponseInput::Items(items)) => { - items.iter().any(|item| item.is_function_call_output()) - } - _ => false, - }; - if has_function_outputs && request.previous_response_id.is_none() { - return Err(errors::ResponseError::InvalidParams( - "function_call_output requires previous_response_id to resume a response" - .to_string(), - )); - } + // Defend the service boundary as well as the HTTP route: this service + // only executes a single stateless request and never uses the + // persistent response repositories supplied to ResponseServiceImpl. + request + .validate() + .and_then(|_| request.validate_stateless()) + .map_err(errors::ResponseError::InvalidParams)?; + let mut request = request; + request.store = Some(false); + request.background = Some(false); // Create a channel for streaming events let (mut tx, rx) = mpsc::unbounded::(); + // Each request gets its own in-memory repositories. This preserves the + // existing event and tool execution flow without creating, reading, or + // updating rows in `responses` or `response_items`. + let (response_repository, response_items_repository) = transient::repositories(); + // Clone necessary references for the async task - let response_repository = self.response_repository.clone(); - let response_items_repository = self.response_items_repository.clone(); let completion_service = self.completion_service.clone(); let conversation_service = self.conversation_service.clone(); let web_search_provider = self.web_search_provider.clone(); @@ -1469,7 +1469,9 @@ impl ResponseServiceImpl { metadata: process_context.request.metadata.clone(), store: process_context.request.store, body_hash: process_context.body_hash.to_string(), - response_id: Some(ctx.response_id.clone()), + // The response ID is an in-memory event identifier only. Do + // not link usage records to a database response row. + response_id: None, skip_provider_chat_signature: false, original_request: None, n: None, diff --git a/crates/services/src/responses/transient.rs b/crates/services/src/responses/transient.rs new file mode 100644 index 000000000..3e023ea5e --- /dev/null +++ b/crates/services/src/responses/transient.rs @@ -0,0 +1,552 @@ +//! Request-scoped, in-memory repositories for stateless Responses requests. +//! +//! These implementations deliberately satisfy the existing response repository +//! interfaces without touching the database. A fresh store is created for each +//! request so response and response-item data are discarded when the request +//! finishes. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::{ + conversations::models::ConversationId, + responses::{models, ports}, + workspace::WorkspaceId, +}; + +#[derive(Default)] +struct TransientResponseStore { + responses: Mutex>, + response_items: Mutex>, +} + +struct TransientResponseItem { + id: Uuid, + response_id: Uuid, + api_key_id: Uuid, + item: models::ResponseOutputItem, +} + +struct TransientResponseRepository { + store: Arc, +} + +struct TransientResponseItemsRepository { + store: Arc, +} + +/// Build repository implementations that live only for one Responses request. +pub(super) fn repositories() -> ( + Arc, + Arc, +) { + let store = Arc::new(TransientResponseStore::default()); + + ( + Arc::new(TransientResponseRepository { + store: store.clone(), + }), + Arc::new(TransientResponseItemsRepository { store }), + ) +} + +fn response_id_string(response_id: Uuid) -> String { + format!("resp_{}", response_id.simple()) +} + +fn default_tools() -> Vec { + vec![models::ResponseTool::WebSearch { + filters: None, + search_context_size: Some("medium".to_string()), + user_location: Some(models::UserLocation { + type_: "approximate".to_string(), + city: None, + country: Some("US".to_string()), + region: None, + timezone: None, + }), + }] +} + +fn initial_response(request: models::CreateResponseRequest) -> (Uuid, models::ResponseObject) { + let response_id = Uuid::new_v4(); + let now = chrono::Utc::now().timestamp(); + + ( + response_id, + models::ResponseObject { + id: response_id_string(response_id), + object: "response".to_string(), + created_at: now, + status: models::ResponseStatus::InProgress, + // Stateless requests never retain a response in the background or + // attach it to a persisted conversation. + background: false, + conversation: None, + error: None, + incomplete_details: None, + instructions: request.instructions, + max_output_tokens: request.max_output_tokens, + max_tool_calls: request.max_tool_calls, + model: request.model, + output: vec![], + parallel_tool_calls: request.parallel_tool_calls.unwrap_or(false), + previous_response_id: None, + next_response_ids: vec![], + prompt_cache_key: request.prompt_cache_key, + prompt_cache_retention: None, + reasoning: None, + safety_identifier: request.safety_identifier, + service_tier: "default".to_string(), + store: false, + temperature: request.temperature.unwrap_or(1.0), + tool_choice: models::ResponseToolChoiceOutput::Auto("auto".to_string()), + tools: request.tools.unwrap_or_else(default_tools), + top_logprobs: 0, + top_p: request.top_p.unwrap_or(1.0), + truncation: "disabled".to_string(), + usage: models::Usage::new(0, 0), + user: None, + metadata: Some(request.metadata.unwrap_or_else(|| serde_json::json!({}))), + }, + ) +} + +fn response_item_id(item: &models::ResponseOutputItem) -> Uuid { + item.id() + .rsplit('_') + .next() + .and_then(|id| Uuid::parse_str(id).ok()) + .unwrap_or_else(Uuid::new_v4) +} + +fn decorate_response_item( + item: &mut models::ResponseOutputItem, + response: &models::ResponseObject, +) { + let response_id = response.id.clone(); + let previous_response_id = response.previous_response_id.clone(); + let created_at = chrono::Utc::now().timestamp(); + + match item { + models::ResponseOutputItem::Message { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + model, + .. + } + | models::ResponseOutputItem::ToolCall { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + model, + .. + } + | models::ResponseOutputItem::WebSearchCall { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + model, + .. + } + | models::ResponseOutputItem::Reasoning { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + model, + .. + } + | models::ResponseOutputItem::McpCall { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + model, + .. + } + | models::ResponseOutputItem::McpApprovalRequest { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + model, + .. + } + | models::ResponseOutputItem::FunctionCall { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + model, + .. + } => { + *item_response_id = response_id; + *item_previous_response_id = previous_response_id; + *next_response_ids = vec![]; + *item_created_at = created_at; + if model.is_empty() { + *model = response.model.clone(); + } + } + models::ResponseOutputItem::FunctionCallOutput { + response_id: item_response_id, + previous_response_id: item_previous_response_id, + next_response_ids, + created_at: item_created_at, + .. + } => { + *item_response_id = response_id; + *item_previous_response_id = previous_response_id; + *next_response_ids = vec![]; + *item_created_at = created_at; + } + models::ResponseOutputItem::McpListTools { .. } => {} + } +} + +#[async_trait] +impl ports::ResponseRepositoryTrait for TransientResponseRepository { + async fn create( + &self, + _workspace_id: WorkspaceId, + _api_key_id: Uuid, + request: models::CreateResponseRequest, + ) -> anyhow::Result { + let (response_id, response) = initial_response(request); + self.store + .responses + .lock() + .map_err(|_| anyhow::anyhow!("Transient response store lock poisoned"))? + .insert(response_id, response.clone()); + Ok(response) + } + + async fn get_by_id( + &self, + id: models::ResponseId, + _workspace_id: WorkspaceId, + ) -> anyhow::Result> { + Ok(self + .store + .responses + .lock() + .map_err(|_| anyhow::anyhow!("Transient response store lock poisoned"))? + .get(&id.0) + .cloned()) + } + + async fn update( + &self, + id: models::ResponseId, + _workspace_id: WorkspaceId, + _output_message: Option, + status: models::ResponseStatus, + usage: Option, + ) -> anyhow::Result> { + let mut responses = self + .store + .responses + .lock() + .map_err(|_| anyhow::anyhow!("Transient response store lock poisoned"))?; + let Some(response) = responses.get_mut(&id.0) else { + return Ok(None); + }; + + response.status = status; + if let Some(usage) = usage { + response.usage = serde_json::from_value(usage) + .map_err(|error| anyhow::anyhow!("Invalid transient response usage: {error}"))?; + } + + Ok(Some(response.clone())) + } + + async fn delete( + &self, + id: models::ResponseId, + _workspace_id: WorkspaceId, + ) -> anyhow::Result { + Ok(self + .store + .responses + .lock() + .map_err(|_| anyhow::anyhow!("Transient response store lock poisoned"))? + .remove(&id.0) + .is_some()) + } + + async fn cancel( + &self, + id: models::ResponseId, + _workspace_id: WorkspaceId, + ) -> anyhow::Result> { + let mut responses = self + .store + .responses + .lock() + .map_err(|_| anyhow::anyhow!("Transient response store lock poisoned"))?; + let Some(response) = responses.get_mut(&id.0) else { + return Ok(None); + }; + + response.status = models::ResponseStatus::Cancelled; + Ok(Some(response.clone())) + } + + async fn list_by_workspace( + &self, + _workspace_id: WorkspaceId, + _limit: i64, + _offset: i64, + ) -> anyhow::Result> { + Ok(self + .store + .responses + .lock() + .map_err(|_| anyhow::anyhow!("Transient response store lock poisoned"))? + .values() + .cloned() + .collect()) + } + + async fn list_by_conversation( + &self, + _conversation_id: ConversationId, + _workspace_id: WorkspaceId, + _limit: i64, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_previous( + &self, + _response_id: models::ResponseId, + _workspace_id: WorkspaceId, + ) -> anyhow::Result> { + Ok(None) + } + + async fn get_latest_in_conversation( + &self, + _conversation_id: ConversationId, + _workspace_id: WorkspaceId, + ) -> anyhow::Result> { + Ok(None) + } + + async fn get_or_create_root_response( + &self, + _conversation_id: ConversationId, + _workspace_id: WorkspaceId, + _api_key_id: Uuid, + ) -> anyhow::Result { + Err(anyhow::anyhow!( + "Conversations are not supported by stateless Responses requests" + )) + } +} + +#[async_trait] +impl ports::ResponseItemRepositoryTrait for TransientResponseItemsRepository { + async fn create( + &self, + response_id: models::ResponseId, + api_key_id: Uuid, + _conversation_id: Option, + mut item: models::ResponseOutputItem, + ) -> anyhow::Result { + let response = self + .store + .responses + .lock() + .map_err(|_| anyhow::anyhow!("Transient response store lock poisoned"))? + .get(&response_id.0) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Transient response not found"))?; + decorate_response_item(&mut item, &response); + + self.store + .response_items + .lock() + .map_err(|_| anyhow::anyhow!("Transient response item store lock poisoned"))? + .push(TransientResponseItem { + id: response_item_id(&item), + response_id: response_id.0, + api_key_id, + item: item.clone(), + }); + + Ok(item) + } + + async fn get_by_id( + &self, + id: models::ResponseItemId, + _workspace_id: WorkspaceId, + ) -> anyhow::Result> { + Ok(self + .store + .response_items + .lock() + .map_err(|_| anyhow::anyhow!("Transient response item store lock poisoned"))? + .iter() + .find(|stored| stored.id == id.0) + .map(|stored| stored.item.clone())) + } + + async fn update( + &self, + id: models::ResponseItemId, + item: models::ResponseOutputItem, + ) -> anyhow::Result { + let mut items = self + .store + .response_items + .lock() + .map_err(|_| anyhow::anyhow!("Transient response item store lock poisoned"))?; + let stored = items + .iter_mut() + .find(|stored| stored.id == id.0) + .ok_or_else(|| anyhow::anyhow!("Transient response item not found"))?; + stored.item = item.clone(); + Ok(item) + } + + async fn delete(&self, id: models::ResponseItemId) -> anyhow::Result { + let mut items = self + .store + .response_items + .lock() + .map_err(|_| anyhow::anyhow!("Transient response item store lock poisoned"))?; + let original_len = items.len(); + items.retain(|stored| stored.id != id.0); + Ok(items.len() != original_len) + } + + async fn list_by_response( + &self, + response_id: models::ResponseId, + ) -> anyhow::Result> { + Ok(self + .store + .response_items + .lock() + .map_err(|_| anyhow::anyhow!("Transient response item store lock poisoned"))? + .iter() + .filter(|stored| stored.response_id == response_id.0) + .map(|stored| stored.item.clone()) + .collect()) + } + + async fn list_by_api_key( + &self, + api_key_id: Uuid, + ) -> anyhow::Result> { + Ok(self + .store + .response_items + .lock() + .map_err(|_| anyhow::anyhow!("Transient response item store lock poisoned"))? + .iter() + .filter(|stored| stored.api_key_id == api_key_id) + .map(|stored| stored.item.clone()) + .collect()) + } + + async fn list_by_conversation( + &self, + _conversation_id: ConversationId, + _workspace_id: WorkspaceId, + _after: Option, + _limit: i64, + ) -> anyhow::Result> { + Ok(vec![]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> models::CreateResponseRequest { + models::CreateResponseRequest { + model: "test-model".to_string(), + input: None, + instructions: None, + conversation: None, + previous_response_id: None, + max_output_tokens: None, + max_tool_calls: None, + temperature: None, + top_p: None, + stream: None, + store: None, + background: None, + tools: None, + tool_choice: None, + parallel_tool_calls: None, + reasoning: None, + include: None, + metadata: None, + safety_identifier: None, + prompt_cache_key: None, + } + } + + #[tokio::test] + async fn repositories_keep_response_data_in_request_scoped_memory() { + let (responses, response_items) = repositories(); + let workspace_id = WorkspaceId(Uuid::new_v4()); + let api_key_id = Uuid::new_v4(); + + let response = responses + .create(workspace_id.clone(), api_key_id, request()) + .await + .unwrap(); + // Omitted `store` and `background` fields default to the stateless + // values when the transient response is constructed. + assert!(!response.store); + assert!(!response.background); + assert!(response.conversation.is_none()); + + let response_id = models::ResponseId( + Uuid::parse_str(response.id.strip_prefix("resp_").unwrap()) + .expect("transient response ID is a UUID"), + ); + let message = models::ResponseOutputItem::Message { + id: format!("msg_{}", Uuid::new_v4().simple()), + response_id: String::new(), + previous_response_id: None, + next_response_ids: vec![], + created_at: 0, + status: models::ResponseItemStatus::Completed, + role: "assistant".to_string(), + content: vec![models::ResponseContentItem::OutputText { + text: "hello".to_string(), + annotations: vec![], + logprobs: vec![], + }], + model: String::new(), + metadata: None, + }; + + response_items + .create(response_id.clone(), api_key_id, None, message) + .await + .unwrap(); + + let items = response_items.list_by_response(response_id).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].response_id(), Some(response.id.as_str())); + } +} diff --git a/docs/local-development.md b/docs/local-development.md index 6a5c2f102..e4e4fb1d0 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -272,7 +272,7 @@ Provider refresh runs every 300s by default | `POST /v1/workspaces/{id}/api-keys` | session | Returns plaintext `key` — store it, it isn't shown again | | `GET /v1/models` | public | OpenAI-compatible model catalog with pricing metadata | | `POST /v1/chat/completions` | API key | OpenAI-compatible. Add `"stream": true` for SSE | -| `POST /v1/responses` | API key | Platform-specific event-streamed responses | +| `POST /v1/responses` | API key | Single-turn no-store response inference; response history is unavailable | | `GET /v1/attestation/report` | API key | TEE attestation (503 outside a CVM unless `DEV=true` in debug builds) | | `GET /v1/attestation/ita-token` | public | Intel Trust Authority JWT wrapper (requires ITA env vars) | | `GET /v1/signature/{chat_id}` | API key | Per-completion signature lookup | From 95b6224a93a914cd795a35f06d13ad86b3fce91d Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:06:26 +0800 Subject: [PATCH 04/31] test: migrate e2e coverage to stateless responses --- crates/api/tests/e2e_all/client_disconnect.rs | 468 +----------- crates/api/tests/e2e_all/function_tools.rs | 680 +----------------- crates/api/tests/e2e_all/main.rs | 2 +- crates/api/tests/e2e_all/mcp.rs | 313 +------- crates/api/tests/e2e_all/message_metadata.rs | 680 +----------------- crates/api/tests/e2e_all/org_system_prompt.rs | 15 +- .../response_signature_verification.rs | 450 ------------ .../api/tests/e2e_all/responses_stateless.rs | 99 +++ crates/api/tests/e2e_all/usage_responses.rs | 28 +- .../api/tests/e2e_all/web_search_citations.rs | 69 +- 10 files changed, 238 insertions(+), 2566 deletions(-) delete mode 100644 crates/api/tests/e2e_all/response_signature_verification.rs create mode 100644 crates/api/tests/e2e_all/responses_stateless.rs diff --git a/crates/api/tests/e2e_all/client_disconnect.rs b/crates/api/tests/e2e_all/client_disconnect.rs index 0509050b1..42ad0483f 100644 --- a/crates/api/tests/e2e_all/client_disconnect.rs +++ b/crates/api/tests/e2e_all/client_disconnect.rs @@ -1,423 +1,20 @@ -// E2E tests for client disconnect scenarios -// -// The mock's with_disconnect_after() simulates a truncated stream (provider ends early), -// which tests that partial responses and usage are correctly saved. +//! Client-disconnect coverage for the supported Chat Completions API. +//! +//! Responses no longer retain response IDs, items, or attestation signatures, +//! so their former persistence-focused disconnect tests are intentionally not +//! retained here. use crate::common::*; -/// Get assistant response item for a conversation -async fn get_assistant_item_from_db( - database: &database::Database, - conversation_id: &str, -) -> Option { - let pool = database.pool(); - let client = pool.get().await.expect("Failed to get database connection"); - - let uuid_str = conversation_id - .strip_prefix("conv_") - .unwrap_or(conversation_id); - let conv_uuid = uuid::Uuid::parse_str(uuid_str).expect("Invalid conversation ID"); - - let rows = client - .query( - "SELECT item FROM response_items WHERE conversation_id = $1 ORDER BY created_at DESC", - &[&conv_uuid], - ) - .await - .expect("Failed to query response_items"); - - for row in rows { - let item: serde_json::Value = row.get("item"); - if item.get("role").and_then(|v| v.as_str()) == Some("assistant") { - return Some(item); - } - } - None -} - -/// Usage record with all relevant fields for testing -#[derive(Debug)] -struct UsageRecord { - input_tokens: i32, - output_tokens: i32, - stop_reason: Option, - response_id: Option, - provider_request_id: Option, - inference_id: Option, -} - -/// Get usage records for an organization -async fn get_usage_records_from_db( - database: &database::Database, - organization_id: uuid::Uuid, -) -> Vec { - let pool = database.pool(); - let client = pool.get().await.expect("Failed to get database connection"); - - let rows = client - .query( - "SELECT input_tokens, output_tokens, stop_reason, response_id, provider_request_id, inference_id - FROM organization_usage_log WHERE organization_id = $1 ORDER BY created_at DESC", - &[&organization_id], - ) - .await - .expect("Failed to query usage"); - - rows.into_iter() - .map(|row| UsageRecord { - input_tokens: row.get("input_tokens"), - output_tokens: row.get("output_tokens"), - stop_reason: row.get("stop_reason"), - response_id: row.get("response_id"), - provider_request_id: row.get("provider_request_id"), - inference_id: row.get("inference_id"), - }) - .collect() -} - -/// Extract text from assistant item -fn extract_text(item: &serde_json::Value) -> Option { - item.get("content")? - .as_array()? - .iter() - .find_map(|c| c.get("text").and_then(|t| t.as_str())) - .map(|s| s.to_string()) -} - -#[tokio::test] -async fn test_response_items_saved_on_disconnect() { - let (server, _pool, mock, database) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id.clone()).await; - let org_uuid = uuid::Uuid::parse_str(&org.id).unwrap(); - - use crate::common::mock_prompts; - - // Configure mock: 10 words, disconnect after 5 - let full_response = "Machine learning is a fascinating field of artificial intelligence today"; - let prompt = mock_prompts::build_prompt("Tell me about machine learning"); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - prompt, - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new(full_response).with_disconnect_after(5), - ) - .await; - - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation: api::models::ConversationObject = conv_resp.json(); - - // Make streaming request - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": conversation.id, - "input": [{"role": "user", "content": [{"type": "input_text", "text": "Tell me about machine learning"}]}], - "stream": true - })) - .await; - assert_eq!(response.status_code(), 200); - let _stream = response.text(); - - // Wait for async DB writes (stream completion + title generation) - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - - // Verify assistant response saved with partial text - let item = get_assistant_item_from_db(&database, &conversation.id) - .await - .expect("Should have assistant item"); - let text = extract_text(&item).expect("Should have text content"); - - assert_eq!(text, "Machine learning is a fascinating"); - assert!(!text.contains("field")); - - // Verify usage recorded with expected token counts - // Note: Title generation may also record usage, so we find the specific record by input/output - // Input: 127 tokens = system prompt (~122 words) + user message ("Tell me about machine learning" = 5 words) - // Output: 5 words before disconnect ("Machine learning is a fascinating") - let usage = get_usage_records_from_db(&database, org_uuid).await; - assert_eq!( - usage.len(), - 2, - "Should have exactly 2 usage records (main request + title generation). Found: {:?}", - usage - ); - - // Find the main request's usage (127 input tokens from system prompt + user msg, 5 output tokens) - let main_request_usage = usage - .iter() - .find(|r| r.input_tokens == 127 && r.output_tokens == 5); - assert!( - main_request_usage.is_some(), - "Should have usage record with 127 input tokens and 5 output tokens. Found: {:?}", - usage - ); - - let main_usage = main_request_usage.unwrap(); - - // Note: The mock's with_disconnect_after() truncates the stream but it still ends normally - // (returns None), so from our perspective it's a "completed" stream. A true client disconnect - // would occur if the client dropped the connection before consuming all chunks, which would - // cause stream_completed to remain false when Drop is called. - assert_eq!( - main_usage.stop_reason.as_deref(), - Some("completed"), - "Stop reason should be 'completed' for stream that ended normally. Found: {:?}", - main_usage.stop_reason - ); - - // Verify response_id is set (this was called from Responses API) - assert!( - main_usage.response_id.is_some(), - "Response ID should be set for Responses API calls. Found: {:?}", - main_usage - ); - - // Verify provider_request_id is set (raw ID from provider) - assert!( - main_usage.provider_request_id.is_some(), - "Provider request ID should be set. Found: {:?}", - main_usage - ); - - // Verify inference_id is set (hashed from provider_request_id) - assert!( - main_usage.inference_id.is_some(), - "Inference ID should be set. Found: {:?}", - main_usage - ); -} - -#[tokio::test] -async fn test_signature_returns_stream_disconnected_on_client_disconnect() { - let (server, _pool, mock, database) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id.clone()).await; - - use crate::common::mock_prompts; - - // Configure mock: 10 words, disconnect after 5 - let full_response = "Machine learning is a fascinating field of artificial intelligence today"; - let prompt = mock_prompts::build_prompt("Tell me about AI"); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - prompt, - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new(full_response).with_disconnect_after(5), - ) - .await; - - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation: api::models::ConversationObject = conv_resp.json(); - - // Make streaming request - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": conversation.id, - "input": [{"role": "user", "content": [{"type": "input_text", "text": "Tell me about AI"}]}], - "stream": true - })) - .await; - assert_eq!(response.status_code(), 200); - - // Parse the response to get response_id - let response_text = response.text(); - let mut response_id: Option = None; - for line_chunk in response_text.split("\n\n") { - for line in line_chunk.lines() { - if let Some(data) = line.strip_prefix("data: ") { - if let Ok(json) = serde_json::from_str::(data) { - if let Some(id) = json - .get("response") - .and_then(|r| r.get("id")) - .and_then(|id| id.as_str()) - { - response_id = Some(id.to_string()); - } - } - } - } - } - let response_id = response_id.expect("Should have response_id from stream"); - - // Wait for async DB writes - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // Manually update the usage record to simulate a client disconnect - // (The mock ends the stream normally, so we need to update the stop_reason manually) - let pool = database.pool(); - let client = pool.get().await.expect("Failed to get database connection"); - let response_uuid_str = response_id.strip_prefix("resp_").unwrap_or(&response_id); - let response_uuid = uuid::Uuid::parse_str(response_uuid_str).expect("Invalid response ID"); - - // Delete any signature that might have been stored (to simulate no signature available) - client - .execute( - "DELETE FROM chat_signatures WHERE chat_id = $1", - &[&response_id], - ) - .await - .expect("Failed to delete signature"); - - // Update the stop_reason to client_disconnect - client - .execute( - "UPDATE organization_usage_log SET stop_reason = 'client_disconnect' WHERE response_id = $1", - &[&response_uuid], - ) - .await - .expect("Failed to update stop_reason"); - - // Now call the signature endpoint - should return 200 with STREAM_DISCONNECTED - let signature_resp = server - .get(&format!("/v1/signature/{response_id}?signing_algo=ecdsa")) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!( - signature_resp.status_code(), - 200, - "Signature endpoint should return 200 for client disconnect. Response: {}", - signature_resp.text() - ); - - let signature_json: serde_json::Value = signature_resp.json(); - assert_eq!( - signature_json.get("error_code").and_then(|v| v.as_str()), - Some("STREAM_DISCONNECTED"), - "Should have STREAM_DISCONNECTED error_code. Response: {:?}", - signature_json - ); - assert_eq!( - signature_json.get("message").and_then(|v| v.as_str()), - Some("Verification not available due to disconnection."), - "Should have expected message. Response: {:?}", - signature_json - ); -} - -#[tokio::test] -async fn test_signature_returns_404_when_not_client_disconnect() { - let (server, _pool, mock, database) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id.clone()).await; - - use crate::common::mock_prompts; - - // Configure mock with normal response - let full_response = "Hello world"; - let prompt = mock_prompts::build_prompt("Say hello"); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - prompt, - )) - .respond_with(inference_providers::mock::ResponseTemplate::new( - full_response, - )) - .await; - - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation: api::models::ConversationObject = conv_resp.json(); - - // Make streaming request - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": conversation.id, - "input": [{"role": "user", "content": [{"type": "input_text", "text": "Say hello"}]}], - "stream": true - })) - .await; - assert_eq!(response.status_code(), 200); - - // Parse the response to get response_id - let response_text = response.text(); - let mut response_id: Option = None; - for line_chunk in response_text.split("\n\n") { - for line in line_chunk.lines() { - if let Some(data) = line.strip_prefix("data: ") { - if let Ok(json) = serde_json::from_str::(data) { - if let Some(id) = json - .get("response") - .and_then(|r| r.get("id")) - .and_then(|id| id.as_str()) - { - response_id = Some(id.to_string()); - } - } - } - } - } - let response_id = response_id.expect("Should have response_id from stream"); - - // Wait for async DB writes - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // Delete any signature that might have been stored - // The usage record has stop_reason = "completed", so signature should return 404 - let pool = database.pool(); - let client = pool.get().await.expect("Failed to get database connection"); - - // Ensure no signature exists - client - .execute( - "DELETE FROM chat_signatures WHERE chat_id = $1", - &[&response_id], - ) - .await - .expect("Failed to delete signature"); - - // Now call the signature endpoint - should return 404 since stop_reason is "completed" - let signature_resp = server - .get(&format!("/v1/signature/{response_id}?signing_algo=ecdsa")) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!( - signature_resp.status_code(), - 404, - "Signature endpoint should return 404 for completed stream without signature. Response: {}", - signature_resp.text() - ); -} - #[tokio::test] -async fn test_chat_completion_signature_returns_stream_disconnected_on_client_disconnect() { +async fn chat_completion_signature_returns_stream_disconnected_on_client_disconnect() { let (server, _pool, mock, database) = setup_test_server_with_pool().await; setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id.clone()).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; use crate::common::mock_prompts; - // Configure mock: 10 words, disconnect after 5 let full_response = "Machine learning is a fascinating field of artificial intelligence today"; let prompt = mock_prompts::build_prompt("Tell me about AI"); mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( @@ -428,7 +25,6 @@ async fn test_chat_completion_signature_returns_stream_disconnected_on_client_di ) .await; - // Make streaming chat completion request let response = server .post("/v1/chat/completions") .add_header("Authorization", format!("Bearer {api_key}")) @@ -440,32 +36,25 @@ async fn test_chat_completion_signature_returns_stream_disconnected_on_client_di .await; assert_eq!(response.status_code(), 200); - // Parse the response to get completion id (chatcmpl-xxx format) let response_text = response.text(); - let mut completion_id: Option = None; - for line in response_text.lines() { - if let Some(data) = line.strip_prefix("data: ") { - if data == "[DONE]" { - continue; - } - if let Ok(json) = serde_json::from_str::(data) { - if let Some(id) = json.get("id").and_then(|id| id.as_str()) { - completion_id = Some(id.to_string()); - break; - } - } - } - } - let completion_id = completion_id.expect("Should have completion_id from stream"); + let completion_id = response_text + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .filter(|data| *data != "[DONE]") + .find_map(|data| { + serde_json::from_str::(data) + .ok() + .and_then(|json| json.get("id").and_then(|id| id.as_str()).map(str::to_owned)) + }) + .expect("Should have completion_id from stream"); - // Wait for async DB writes tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - // Manually update the usage record to simulate a client disconnect - let pool = database.pool(); - let client = pool.get().await.expect("Failed to get database connection"); - - // Delete any signature that might have been stored + let client = database + .pool() + .get() + .await + .expect("Failed to get database connection"); client .execute( "DELETE FROM chat_signatures WHERE chat_id = $1", @@ -473,8 +62,6 @@ async fn test_chat_completion_signature_returns_stream_disconnected_on_client_di ) .await .expect("Failed to delete signature"); - - // Update the stop_reason to client_disconnect (by provider_request_id for chat completions) client .execute( "UPDATE organization_usage_log SET stop_reason = 'client_disconnect' WHERE provider_request_id = $1", @@ -483,7 +70,6 @@ async fn test_chat_completion_signature_returns_stream_disconnected_on_client_di .await .expect("Failed to update stop_reason"); - // Now call the signature endpoint - should return 200 with STREAM_DISCONNECTED let signature_resp = server .get(&format!("/v1/signature/{completion_id}?signing_algo=ecdsa")) .add_header("Authorization", format!("Bearer {api_key}")) @@ -499,14 +85,10 @@ async fn test_chat_completion_signature_returns_stream_disconnected_on_client_di let signature_json: serde_json::Value = signature_resp.json(); assert_eq!( signature_json.get("error_code").and_then(|v| v.as_str()), - Some("STREAM_DISCONNECTED"), - "Should have STREAM_DISCONNECTED error_code. Response: {:?}", - signature_json + Some("STREAM_DISCONNECTED") ); assert_eq!( signature_json.get("message").and_then(|v| v.as_str()), - Some("Verification not available due to disconnection."), - "Should have expected message. Response: {:?}", - signature_json + Some("Verification not available due to disconnection.") ); } diff --git a/crates/api/tests/e2e_all/function_tools.rs b/crates/api/tests/e2e_all/function_tools.rs index 11a33c60a..82093ac55 100644 --- a/crates/api/tests/e2e_all/function_tools.rs +++ b/crates/api/tests/e2e_all/function_tools.rs @@ -1,678 +1,60 @@ -//! E2E tests for external function tools in the responses API. +//! E2E coverage for retired client-executed function tooling. //! -//! This test simulates a realistic multi-turn conversation with external function tools: -//! 1. First request: LLM requests function call, response is incomplete with FunctionCall output -//! 2. Second request: Client provides function output, LLM produces final response -//! -//! Unlike MCP tools (server-executed), function tools are executed by the client externally. -//! The API returns FunctionCall items and pauses until the client submits FunctionCallOutput. +//! Stateless Responses requests cannot pause for a client to execute a +//! function and submit a continuation. Keep the boundary assertions here +//! instead of the former multi-turn lifecycle tests. use crate::common::*; -/// Test a single function call flow: -/// 1. Client sends request with function tool definition -/// 2. LLM calls the function → response is incomplete with FunctionCall in output -/// 3. Client sends FunctionCallOutput with the result -/// 4. LLM produces final response → response is complete -#[tokio::test] -async fn test_function_tool_single_call() { - let (server, _, mock, _) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({"name": "Function Tool Test"})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation = conv_resp.json::(); - - // Define a function tool - let function_tool = serde_json::json!({ - "type": "function", - "name": "get_weather", - "description": "Get the current weather for a location", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city name" - } - }, - "required": ["location"] - } - }); - - // ======================================== - // Turn 1: LLM requests function call - // ======================================== - println!("Turn 1: LLM requests function call..."); - - let turn1_user = "What's the weather in Tokyo?"; - - // Mock the LLM to request a tool call - use crate::common::mock_prompts; - let turn1_prompt = mock_prompts::build_prompt(turn1_user); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - turn1_prompt, - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new("").with_tool_calls(vec![ - inference_providers::mock::ToolCall::new("get_weather", r#"{"location": "Tokyo"}"#), - ]), - ) - .await; - - let resp1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "input": turn1_user, - "stream": false, - "tools": [function_tool.clone()] - })) - .await; - - assert_eq!(resp1.status_code(), 200, "Turn 1 failed: {}", resp1.text()); - let resp1_obj = resp1.json::(); - - // Response should be incomplete - waiting for function output - assert_eq!( - resp1_obj.status, - api::models::ResponseStatus::Incomplete, - "Turn 1 should be incomplete (waiting for function output)" - ); - - // Extract FunctionCall from output - let function_call = resp1_obj - .output - .iter() - .find_map(|item| { - if let api::models::ResponseOutputItem::FunctionCall { - call_id, - name, - arguments, - .. - } = item - { - Some((call_id.clone(), name.clone(), arguments.clone())) - } else { - None - } - }) - .expect("Turn 1 should return FunctionCall"); - - let (call_id, tool_name, arguments) = function_call; - assert_eq!(tool_name, "get_weather"); - assert!(arguments.contains("Tokyo")); - println!( - " ✓ Received FunctionCall: call_id={}, name={}, arguments={}", - call_id, tool_name, arguments - ); - - // ======================================== - // Turn 2: Client provides function output - // ======================================== - println!("Turn 2: Client provides function output..."); - - let function_output = r#"{"temperature": 22, "conditions": "partly cloudy", "humidity": 65}"#; - - // Mock the LLM response after receiving tool result - let turn2_with_tool_result_prompt = - mock_prompts::build_prompt(&format!("{} {}", turn1_user, function_output)); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - turn2_with_tool_result_prompt, - )) - .respond_with(inference_providers::mock::ResponseTemplate::new( - "The current weather in Tokyo is 22°C with partly cloudy skies and 65% humidity. It's a pleasant day!", - )) - .await; - - let resp2 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "previous_response_id": resp1_obj.id, - "input": [{ - "type": "function_call_output", - "call_id": call_id, - "output": function_output - }], - "stream": false, - "tools": [function_tool.clone()] - })) - .await; - - assert_eq!(resp2.status_code(), 200, "Turn 2 failed: {}", resp2.text()); - let resp2_obj = resp2.json::(); - - // Response should be complete - assert_eq!( - resp2_obj.status, - api::models::ResponseStatus::Completed, - "Turn 2 should be completed" - ); - - // Verify the final response contains the weather information - let final_message = resp2_obj - .output - .iter() - .find(|item| matches!(item, api::models::ResponseOutputItem::Message { .. })); - assert!( - final_message.is_some(), - "Turn 2 should have a message output. Got: {:?}", - resp2_obj.output - ); - - // Extract text from the message content - let text = - if let api::models::ResponseOutputItem::Message { content, .. } = final_message.unwrap() { - assert!(!content.is_empty(), "message content should not be empty"); - match &content[0] { - api::models::ResponseOutputContent::OutputText { text, .. } => text.clone(), - _ => panic!("Expected OutputText content"), - } - } else { - panic!("Expected Message variant"); - }; - - assert!( - text.contains("Tokyo") || text.contains("22") || text.contains("cloudy"), - "Final response should reference weather. Got: {}", - text - ); - println!(" ✓ LLM produced final response: {}", text); - println!("\n✅ Function tool single call test passed!"); -} - -/// Test parallel function calls: -/// 1. LLM requests multiple function calls at once -/// 2. Client provides all function outputs in one request -/// 3. LLM produces final response -#[tokio::test] -async fn test_function_tool_parallel_calls() { - let (server, _, mock, _) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({"name": "Parallel Function Tool Test"})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation = conv_resp.json::(); - - // Define function tools - let weather_tool = serde_json::json!({ - "type": "function", - "name": "get_weather", - "description": "Get the current weather for a location", - "parameters": { - "type": "object", - "properties": { - "location": {"type": "string"} - }, - "required": ["location"] - } - }); - - let time_tool = serde_json::json!({ - "type": "function", - "name": "get_time", - "description": "Get the current time for a timezone", - "parameters": { - "type": "object", - "properties": { - "timezone": {"type": "string"} - }, - "required": ["timezone"] - } - }); - - // ======================================== - // Turn 1: LLM requests multiple function calls - // ======================================== - println!("Turn 1: LLM requests multiple function calls..."); - - let turn1_user = "What's the weather and current time in New York?"; - - use crate::common::mock_prompts; - let turn1_prompt = mock_prompts::build_prompt(turn1_user); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - turn1_prompt, - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new("").with_tool_calls(vec![ - inference_providers::mock::ToolCall::new("get_weather", r#"{"location": "New York"}"#), - inference_providers::mock::ToolCall::new( - "get_time", - r#"{"timezone": "America/New_York"}"#, - ), - ]), - ) - .await; - - let resp1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "input": turn1_user, - "stream": false, - "tools": [weather_tool.clone(), time_tool.clone()] - })) - .await; - - assert_eq!(resp1.status_code(), 200, "Turn 1 failed: {}", resp1.text()); - let resp1_obj = resp1.json::(); - - // Response should be incomplete - assert_eq!( - resp1_obj.status, - api::models::ResponseStatus::Incomplete, - "Turn 1 should be incomplete" - ); - - // Extract all FunctionCalls from output - let function_calls: Vec<_> = resp1_obj - .output - .iter() - .filter_map(|item| { - if let api::models::ResponseOutputItem::FunctionCall { - call_id, - name, - arguments, - .. - } = item - { - Some((call_id.clone(), name.clone(), arguments.clone())) - } else { - None - } - }) - .collect(); - - assert_eq!( - function_calls.len(), - 2, - "Should have 2 FunctionCalls, got: {:?}", - function_calls - ); - println!(" ✓ Received {} FunctionCalls", function_calls.len()); - - // Find the call_ids for each function - let weather_call = function_calls - .iter() - .find(|(_, name, _)| name == "get_weather") - .expect("Should have get_weather call"); - let time_call = function_calls - .iter() - .find(|(_, name, _)| name == "get_time") - .expect("Should have get_time call"); - - println!( - " - get_weather: call_id={}, args={}", - weather_call.0, weather_call.2 - ); - println!( - " - get_time: call_id={}, args={}", - time_call.0, time_call.2 - ); - - // ======================================== - // Turn 2: Client provides all function outputs - // ======================================== - println!("Turn 2: Client provides all function outputs..."); - - let weather_output = r#"{"temperature": 18, "conditions": "sunny"}"#; - let time_output = r#"{"time": "2:30 PM", "date": "2024-01-15"}"#; - - // Mock the LLM response after receiving both tool results - let turn2_with_tool_results_prompt = mock_prompts::build_prompt(&format!( - "{} {} {}", - turn1_user, weather_output, time_output - )); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - turn2_with_tool_results_prompt, - )) - .respond_with(inference_providers::mock::ResponseTemplate::new( - "In New York, it's currently 2:30 PM on January 15th. The weather is sunny with a temperature of 18°C.", - )) - .await; - - let resp2 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "previous_response_id": resp1_obj.id, - "input": [ - { - "type": "function_call_output", - "call_id": weather_call.0, - "output": weather_output - }, - { - "type": "function_call_output", - "call_id": time_call.0, - "output": time_output - } - ], - "stream": false, - "tools": [weather_tool.clone(), time_tool.clone()] - })) - .await; - - assert_eq!(resp2.status_code(), 200, "Turn 2 failed: {}", resp2.text()); - let resp2_obj = resp2.json::(); - - // Response should be complete - assert_eq!( - resp2_obj.status, - api::models::ResponseStatus::Completed, - "Turn 2 should be completed" - ); - - // Verify the final response - let final_message = resp2_obj - .output - .iter() - .find(|item| matches!(item, api::models::ResponseOutputItem::Message { .. })); - assert!( - final_message.is_some(), - "Turn 2 should have a message output" - ); - - let text = - if let api::models::ResponseOutputItem::Message { content, .. } = final_message.unwrap() { - match &content[0] { - api::models::ResponseOutputContent::OutputText { text, .. } => text.clone(), - _ => panic!("Expected OutputText content"), - } - } else { - panic!("Expected Message variant"); - }; - - assert!( - text.contains("New York") || text.contains("2:30") || text.contains("18"), - "Final response should reference location, time, or weather. Got: {}", - text - ); - println!(" ✓ LLM produced final response: {}", text); - println!("\n✅ Function tool parallel calls test passed!"); -} - -/// Regression test: FunctionCallOutput + Message in same input must preserve order. -/// Tool results must immediately follow the assistant message with tool_calls; -/// a user message in between violates the LLM provider contract. -/// With the fix: [assistant+tool_calls, tool_result, user_message] -/// Bug: [assistant+tool_calls, user_message, tool_result] - wrong #[tokio::test] -async fn test_function_output_and_message_ordering() { - let (server, _, mock, _) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; +async fn function_tools_are_rejected_by_stateless_responses() { + let server = setup_test_server().await; let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({"name": "Function Output + Message Ordering Test"})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation = conv_resp.json::(); - - let function_tool = serde_json::json!({ - "type": "function", - "name": "get_weather", - "description": "Get weather", - "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]} - }); - - // Turn 1: LLM requests function call - let turn1_user = "What's the weather in Paris?"; - use crate::common::mock_prompts; - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - mock_prompts::build_prompt(turn1_user), - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new("").with_tool_calls(vec![ - inference_providers::mock::ToolCall::new("get_weather", r#"{"location": "Paris"}"#), - ]), - ) - .await; - - let resp1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "input": turn1_user, - "stream": false, - "tools": [function_tool.clone()] - })) - .await; - - assert_eq!(resp1.status_code(), 200, "Turn 1 failed: {}", resp1.text()); - let resp1_obj = resp1.json::(); - assert_eq!( - resp1_obj.status, - api::models::ResponseStatus::Incomplete, - "Turn 1 should be incomplete" - ); - - let function_call = resp1_obj - .output - .iter() - .find_map(|item| { - if let api::models::ResponseOutputItem::FunctionCall { call_id, .. } = item { - Some(call_id.clone()) - } else { - None - } - }) - .expect("Turn 1 should return FunctionCall"); - - let function_output = r#"{"temperature": 15, "conditions": "cloudy"}"#; - let follow_up_message = "Thanks! Is it going to rain tomorrow?"; - - // Correct order: tool_result before user message (LLM provider contract) - // build_prompt concatenates text in message order: turn1, tool_output, follow_up - let expected_prompt = mock_prompts::build_prompt(&format!( - "{} {} {}", - turn1_user, function_output, follow_up_message - )); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - expected_prompt, - )) - .respond_with(inference_providers::mock::ResponseTemplate::new( - "Based on the current cloudy conditions, there's a 60% chance of rain tomorrow.", - )) - .await; - - // Turn 2: BOTH FunctionCallOutput AND Message - order matters - let resp2 = server + let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "previous_response_id": resp1_obj.id, - "input": [ - {"type": "function_call_output", "call_id": function_call, "output": function_output}, - {"type": "message", "role": "user", "content": follow_up_message} - ], - "stream": false, - "tools": [function_tool.clone()] + "model": "test-model", + "input": "What is the weather?", + "store": false, + "tools": [{ + "type": "function", + "name": "get_weather", + "parameters": {"type": "object"} + }] })) .await; - assert_eq!(resp2.status_code(), 200, "Turn 2 failed: {}", resp2.text()); - let resp2_obj = resp2.json::(); - assert_eq!( - resp2_obj.status, - api::models::ResponseStatus::Completed, - "Turn 2 should complete - wrong message ordering may cause provider errors" - ); - - let final_message = resp2_obj - .output - .iter() - .find(|item| matches!(item, api::models::ResponseOutputItem::Message { .. })); - assert!(final_message.is_some(), "Turn 2 should have message output"); - println!("✅ FunctionCallOutput + Message ordering test passed!"); + assert_eq!(response.status_code(), 400); + let error = response.json::(); + assert_eq!(error.error.r#type, "invalid_request_error"); + assert!(error.error.message.contains("function tools")); } -/// Test function tool with no previous_response_id (first turn with function output). -/// This tests the edge case where a client might try to submit function output -/// without a previous response context. #[tokio::test] -async fn test_function_output_without_previous_response_fails() { - let (server, _, _, _) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; +async fn function_continuations_are_rejected_by_stateless_responses() { + let server = setup_test_server().await; let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({"name": "Invalid Function Output Test"})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation = conv_resp.json::(); - - // Try to submit function output without a previous response - let resp = server + let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, + "model": "test-model", + "store": false, "input": [{ "type": "function_call_output", - "call_id": "fc_nonexistent", - "output": "some result" - }], - "stream": false - })) - .await; - - // This should fail because there's no previous FunctionCall to match - // The exact error depends on implementation, but it should not succeed - let status = resp.status_code(); - println!( - "Response status when submitting orphan function output: {}", - status - ); - - // Either 400 (bad request) or 404 (function call not found) is acceptable - assert!( - status == 400 || status == 404, - "Should reject orphan function output with 400 or 404, got: {}", - status - ); - - println!("✅ Orphan function output correctly rejected!"); -} - -/// Test that function tools and MCP tools can coexist in the same request. -/// The LLM might call a function tool, which should pause for client execution. -#[tokio::test] -async fn test_function_tool_coexists_with_builtin_tools() { - let (server, _, mock, _) = setup_test_server_with_pool().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({"name": "Mixed Tools Test"})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation = conv_resp.json::(); - - // Define a custom function tool - let custom_tool = serde_json::json!({ - "type": "function", - "name": "search_database", - "description": "Search the internal database", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"} - }, - "required": ["query"] - } - }); - - // LLM calls the custom function - let turn1_user = "Search for user records with email containing 'test'"; - - use crate::common::mock_prompts; - let turn1_prompt = mock_prompts::build_prompt(turn1_user); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - turn1_prompt, - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new("").with_tool_calls(vec![ - inference_providers::mock::ToolCall::new( - "search_database", - r#"{"query": "email:*test*"}"#, - ), - ]), - ) - .await; - - let resp1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "input": turn1_user, - "stream": false, - "tools": [custom_tool.clone()] + "call_id": "call_example", + "output": "{\"temperature\":22}" + }] })) .await; - assert_eq!(resp1.status_code(), 200, "Turn 1 failed: {}", resp1.text()); - let resp1_obj = resp1.json::(); - - // Should be incomplete waiting for function output - assert_eq!( - resp1_obj.status, - api::models::ResponseStatus::Incomplete, - "Should be incomplete waiting for function output" - ); - - // Verify we got a FunctionCall for our custom tool - let has_function_call = resp1_obj.output.iter().any(|item| { - matches!( - item, - api::models::ResponseOutputItem::FunctionCall { name, .. } if name == "search_database" - ) - }); - assert!( - has_function_call, - "Should have FunctionCall for search_database" - ); - - println!("✅ Custom function tool works alongside other tools!"); + assert_eq!(response.status_code(), 400); + let error = response.json::(); + assert_eq!(error.error.r#type, "invalid_request_error"); + assert!(error.error.message.contains("function continuation")); } diff --git a/crates/api/tests/e2e_all/main.rs b/crates/api/tests/e2e_all/main.rs index 790ac6958..feac167bb 100644 --- a/crates/api/tests/e2e_all/main.rs +++ b/crates/api/tests/e2e_all/main.rs @@ -68,7 +68,7 @@ mod reporting_usage; mod repositories; mod request_id_contract; mod rerank; -mod response_signature_verification; +mod responses_stateless; mod score; mod serving_provider; mod session_logout; diff --git a/crates/api/tests/e2e_all/mcp.rs b/crates/api/tests/e2e_all/mcp.rs index f7b34f06b..17b8f43b5 100644 --- a/crates/api/tests/e2e_all/mcp.rs +++ b/crates/api/tests/e2e_all/mcp.rs @@ -1,300 +1,63 @@ -//! E2E tests for MCP (Model Context Protocol) tool support in the responses API. +//! E2E coverage for retired client-mediated MCP flows. //! -//! This test simulates a realistic multi-turn conversation with MCP tools: -//! 1. First request: discovers tools, returns mcp_list_tools -//! 2. Second request: client sends cached tools, LLM requests tool call, approval required -//! 3. Third request: client sends approval, tool executes, LLM produces final response +//! The stateless Responses API still permits server-side MCP work that can +//! finish in one request, but it cannot retain an approval request for a +//! client to resume later. use crate::common::*; -use services::responses::models::McpDiscoveredTool; -use services::responses::tools::{MockMcpClient, MockMcpClientFactory}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; #[tokio::test] -async fn test_mcp_multi_turn_conversation_with_approval() { - // Track list_tools calls to verify caching works - let list_tools_call_count = Arc::new(AtomicUsize::new(0)); - let call_count_clone = list_tools_call_count.clone(); - - // Create mock factory - let mut mock_factory = MockMcpClientFactory::new(); - mock_factory - .expect_create_client() - .withf(|url: &str, _| url == "https://example.com/mcp") - .returning(move |_, _| { - let count = call_count_clone.clone(); - let mut client = MockMcpClient::new(); - - // list_tools increments counter - client.expect_list_tools().returning(move || { - count.fetch_add(1, Ordering::SeqCst); - Ok(vec![McpDiscoveredTool { - name: "get_weather".to_string(), - description: Some("Get weather for a location".to_string()), - input_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"] - })), - annotations: None, - }]) - }); - - // call_tool returns weather data - client - .expect_call_tool() - .withf(|name: &str, _| name == "get_weather") - .returning(|_, args| { - let location = args - .get("location") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - Ok(format!("Weather in {}: Sunny, 72°F", location)) - }); - - Ok(Box::new(client) as Box) - }); - - let mcp_factory = Arc::new(mock_factory); - let (server, _pool, mock) = setup_test_server_with_mcp_factory(mcp_factory).await; - setup_qwen_model(&server).await; +async fn mcp_tools_requiring_approval_are_rejected_by_stateless_responses() { + let server = setup_test_server().await; let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; - // Create conversation - let conv_resp = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({"name": "MCP Multi-turn Test"})) - .await; - assert_eq!(conv_resp.status_code(), 201); - let conversation = conv_resp.json::(); - - let mcp_tool = serde_json::json!({ - "type": "mcp", - "server_label": "weather_server", - "server_url": "https://example.com/mcp", - "require_approval": "always" - }); - - // ======================================== - // Turn 1: Tool discovery - // ======================================== - println!("Turn 1: Tool discovery..."); - - use crate::common::mock_prompts; - let turn1_prompt = mock_prompts::build_prompt("What can you help me with?"); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt(turn1_prompt)) - .respond_with(inference_providers::mock::ResponseTemplate::new( - "I can check the weather for you using the get_weather tool. What location would you like to know about?", - )) - .await; - - let resp1 = server + let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "input": "What can you help me with?", - "stream": false, - "tools": [mcp_tool.clone()] + "model": "test-model", + "input": "Check the weather", + "store": false, + "tools": [{ + "type": "mcp", + "server_label": "weather", + "server_url": "https://example.com/mcp", + "require_approval": "always" + }] })) .await; - assert_eq!(resp1.status_code(), 200, "Turn 1 failed: {}", resp1.text()); - let resp1_obj = resp1.json::(); - assert_eq!(resp1_obj.status, api::models::ResponseStatus::Completed); - assert_eq!( - list_tools_call_count.load(Ordering::SeqCst), - 1, - "list_tools should be called once" - ); - - // Extract mcp_list_tools for caching - let mcp_list_tools = resp1_obj - .output - .iter() - .find(|item| matches!(item, api::models::ResponseOutputItem::McpListTools { .. })) - .expect("Turn 1 should return mcp_list_tools"); - - // Verify the discovered tools - if let api::models::ResponseOutputItem::McpListTools { tools, .. } = mcp_list_tools { - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].name, "get_weather"); - } else { - panic!("Expected McpListTools variant"); - } - - println!(" ✓ Discovered 1 tool: get_weather"); - - // ======================================== - // Turn 2: Tool call requires approval - // ======================================== - println!("Turn 2: Tool invocation with cached tools (requires approval)..."); - - // LLM will request a tool call - let turn1_user = "What can you help me with?"; - let turn1_assistant = "I can check the weather for you using the get_weather tool. What location would you like to know about?"; - let turn2_user = "What's the weather in San Francisco?"; - - // LLM requests tool call - but approval is required so tool won't execute yet - let turn2_prompt = mock_prompts::build_prompt(&format!( - "{} {} {}", - turn1_user, turn1_assistant, turn2_user - )); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - turn2_prompt, - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new("").with_tool_calls(vec![ - inference_providers::mock::ToolCall::new( - "weather_server:get_weather", - r#"{"location": "San Francisco"}"#, - ), - ]), - ) - .await; - - let resp2 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "input": [ - mcp_list_tools, - {"type": "message", "role": "user", "content": "What's the weather in San Francisco?"} - ], - "stream": false, - "tools": [mcp_tool.clone()] - })) - .await; - - assert_eq!(resp2.status_code(), 200, "Turn 2 failed: {}", resp2.text()); - let resp2_obj = resp2.json::(); - - // Response should be incomplete - waiting for approval - assert_eq!( - resp2_obj.status, - api::models::ResponseStatus::Incomplete, - "Turn 2 should be incomplete (waiting for approval)" - ); - - // Verify list_tools was NOT called again (caching worked) - assert_eq!( - list_tools_call_count.load(Ordering::SeqCst), - 1, - "list_tools should NOT be called again when cached" - ); - println!(" ✓ list_tools was not called (cache hit)"); - - // Extract mcp_approval_request from output - let (approval_request_id, tool_name, arguments) = resp2_obj - .output - .iter() - .find_map(|item| { - if let api::models::ResponseOutputItem::McpApprovalRequest { - id, - name, - arguments, - .. - } = item - { - Some((id.clone(), name.clone(), arguments.clone())) - } else { - None - } - }) - .expect("Turn 2 should return mcp_approval_request"); - - assert_eq!(tool_name, "get_weather"); - assert!(arguments.contains("San Francisco")); - println!( - " ✓ Received approval request: {} for tool '{}'", - approval_request_id, tool_name - ); - - // ======================================== - // Turn 3: Approve and execute tool - // ======================================== - println!("Turn 3: Approving tool call and getting result..."); - - let tool_result = "Weather in San Francisco: Sunny, 72°F"; + assert_eq!(response.status_code(), 400); + let error = response.json::(); + assert_eq!(error.error.r#type, "invalid_request_error"); + assert!(error.error.message.contains("require approval")); +} - // After approval, tool executes and LLM produces final response - // The tool result is appended as a "tool" role message - let turn3_with_tool_result_prompt = mock_prompts::build_prompt(&format!( - "{} {} {} {}", - turn1_user, turn1_assistant, turn2_user, tool_result - )); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - turn3_with_tool_result_prompt, - )) - .respond_with(inference_providers::mock::ResponseTemplate::new( - "The weather in San Francisco is currently sunny and 72°F. Perfect weather for outdoor activities!", - )) - .await; +#[tokio::test] +async fn mcp_approval_continuations_are_rejected_by_stateless_responses() { + let server = setup_test_server().await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; - let resp3 = server + let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "conversation": {"id": conversation.id}, - "previous_response_id": resp2_obj.id, - "input": [ - mcp_list_tools, - { - "type": "mcp_approval_response", - "approval_request_id": approval_request_id, - "approve": true - } - ], - "stream": false, - "tools": [mcp_tool.clone()] + "model": "test-model", + "store": false, + "input": [{ + "type": "mcp_approval_response", + "approval_request_id": "mcpr_example", + "approve": true + }] })) .await; - assert_eq!(resp3.status_code(), 200, "Turn 3 failed: {}", resp3.text()); - let resp3_obj = resp3.json::(); - - // Verify the final response contains the weather information - let final_message = resp3_obj - .output - .iter() - .find(|item| matches!(item, api::models::ResponseOutputItem::Message { .. })); - assert!( - final_message.is_some(), - "Turn 3 should have a message output. Got: {:?}", - resp3_obj.output - ); - - // Extract text from the message content - let text = - if let api::models::ResponseOutputItem::Message { content, .. } = final_message.unwrap() { - assert!(!content.is_empty(), "message content should not be empty"); - match &content[0] { - api::models::ResponseOutputContent::OutputText { text, .. } => text.clone(), - _ => panic!("Expected OutputText content"), - } - } else { - panic!("Expected Message variant"); - }; - - assert!( - text.contains("San Francisco") || text.contains("72°F") || text.contains("sunny"), - "Final response should reference weather. Got: {}", - text - ); - println!(" ✓ LLM produced final response: {}", text); - - // Verify the conversation completed successfully - assert_eq!(resp3_obj.status, api::models::ResponseStatus::Completed); - - println!(" ✓ Response completed successfully"); - println!("\n✅ MCP multi-turn conversation with approval test passed!"); + assert_eq!(response.status_code(), 400); + let error = response.json::(); + assert_eq!(error.error.r#type, "invalid_request_error"); + assert!(error.error.message.contains("MCP approval continuation")); } /// A foreign or unknown MCP approval_request_id must be rejected BEFORE the diff --git a/crates/api/tests/e2e_all/message_metadata.rs b/crates/api/tests/e2e_all/message_metadata.rs index a5b700949..ae6530232 100644 --- a/crates/api/tests/e2e_all/message_metadata.rs +++ b/crates/api/tests/e2e_all/message_metadata.rs @@ -1,687 +1,77 @@ -// E2E tests for message-level metadata in Response API +//! E2E coverage for metadata accepted by a stateless Responses request. use crate::common::*; use serde_json::json; -/// Test that input message metadata is preserved through create response and retrieved via list_input_items #[tokio::test] -async fn test_input_message_metadata_preserved() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - // Setup a model for testing +async fn request_metadata_is_returned_by_a_stateless_response() { + let (server, _pool, mock, _database) = setup_test_server_with_pool().await; let model = setup_qwen_model(&server).await; - - // Create a conversation first - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create response with input message that has metadata - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "input": [{ - "role": "user", - "content": "Hello, world!", - "metadata": { - "source": "test", - "custom_key": "custom_value", - "nested": {"foo": "bar"} - } - }], - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response.status_code(), 200); - let response_obj: api::models::ResponseObject = response.json(); - - // List input items and verify metadata is preserved - let input_items_response = server - .get(format!("/v1/responses/{}/input_items", response_obj.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(input_items_response.status_code(), 200); - let input_items: api::models::ResponseInputItemList = input_items_response.json(); - - assert_eq!(input_items.data.len(), 1); - let input_item = &input_items.data[0]; - assert_eq!(input_item.role, "user"); - - // Verify metadata was preserved - let metadata = input_item - .metadata - .as_ref() - .expect("metadata should be present"); - assert_eq!(metadata["source"], "test"); - assert_eq!(metadata["custom_key"], "custom_value"); - assert_eq!(metadata["nested"]["foo"], "bar"); -} - -/// Test that input message without metadata works (backward compatibility) -#[tokio::test] -async fn test_input_message_without_metadata() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; - let model = setup_qwen_model(&server).await; + mock.set_default_response(inference_providers::mock::ResponseTemplate::new( + "metadata reply", + )) + .await; - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create response with input message without metadata (old format) + let metadata = json!({ + "source": "e2e", + "request_id": "client-managed-context", + }); let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&json!({ "model": model, - "conversation": { - "id": conversation.id, - }, "input": [{ "role": "user", - "content": "Hello without metadata" + "content": "Hello, world!", + "metadata": {"source": "client"} }], + "metadata": metadata, + "store": false, "stream": false, "max_output_tokens": 10 })) .await; - assert_eq!(response.status_code(), 200); - let response_obj: api::models::ResponseObject = response.json(); - - // List input items and verify it works without metadata - let input_items_response = server - .get(format!("/v1/responses/{}/input_items", response_obj.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(input_items_response.status_code(), 200); - let input_items: api::models::ResponseInputItemList = input_items_response.json(); - - assert_eq!(input_items.data.len(), 1); - let input_item = &input_items.data[0]; - assert_eq!(input_item.role, "user"); - - // Metadata should be None when not provided - assert!( - input_item.metadata.is_none(), - "metadata should be None when not provided" + assert_eq!(response.status_code(), 200, "{}", response.text()); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store") ); + let response: api::models::ResponseObject = response.json(); + assert!(!response.store); + assert_eq!(response.metadata, Some(metadata)); } -/// Test that oversized input message metadata is rejected #[tokio::test] -async fn test_input_message_metadata_size_limit() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD +async fn oversized_input_metadata_is_rejected_without_a_conversation() { + let server = setup_test_server().await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; + let large_string = "x".repeat(17 * 1024); - let model = setup_qwen_model(&server).await; - - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create large metadata that exceeds 16KB limit - let large_string = "x".repeat(17 * 1024); // 17KB - - // Create response with oversized metadata let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, + "model": "test-model", "input": [{ "role": "user", "content": "Hello", - "metadata": { - "large_field": large_string - } + "metadata": {"large_field": large_string} }], - "stream": false, - "max_output_tokens": 10 + "store": false, + "stream": false })) .await; - // Should be rejected with 400 Bad Request assert_eq!(response.status_code(), 400); let error: api::models::ErrorResponse = response.json(); - assert!( - error.error.message.contains("metadata is too large"), - "Error message should mention metadata size, got: {}", - error.error.message - ); -} - -/// Test that simple text input still works (without metadata support) -#[tokio::test] -async fn test_simple_text_input_no_metadata() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - let model = setup_qwen_model(&server).await; - - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create response with simple text input (not array format) - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "input": "Simple text input", - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response.status_code(), 200); - let response_obj: api::models::ResponseObject = response.json(); - - // List input items - let input_items_response = server - .get(format!("/v1/responses/{}/input_items", response_obj.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(input_items_response.status_code(), 200); - let input_items: api::models::ResponseInputItemList = input_items_response.json(); - - assert_eq!(input_items.data.len(), 1); - let input_item = &input_items.data[0]; - assert_eq!(input_item.role, "user"); - - // Simple text input cannot carry metadata - assert!( - input_item.metadata.is_none(), - "Simple text input should not have metadata" - ); -} - -/// Test that conversation items include metadata from user messages -#[tokio::test] -async fn test_conversation_items_include_user_message_metadata() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - let model = setup_qwen_model(&server).await; - - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create response with input message that has metadata - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "input": [{ - "role": "user", - "content": "Hello, world!", - "metadata": { - "author_id": "user123", - "author_name": "Test User", - "source": "test", - "custom_key": "custom_value" - } - }], - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response.status_code(), 200); - let _response_obj: api::models::ResponseObject = response.json(); - - // List conversation items and verify metadata is present - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items: api::models::ConversationItemList = items_response.json(); - - // Find the user message item - let user_message = items - .data - .iter() - .find(|item| matches!(item, api::models::ConversationItem::Message { role, .. } if role == "user")) - .expect("Should find user message in conversation items"); - - if let api::models::ConversationItem::Message { metadata, .. } = user_message { - let metadata = metadata - .as_ref() - .expect("User message should have metadata"); - - // Verify author metadata is present - assert_eq!(metadata["author_id"], "user123"); - assert_eq!(metadata["author_name"], "Test User"); - assert_eq!(metadata["source"], "test"); - assert_eq!(metadata["custom_key"], "custom_value"); - } else { - panic!("Expected Message item"); - } -} - -/// Test that conversation items include metadata from multiple messages -#[tokio::test] -async fn test_conversation_items_include_multiple_message_metadata() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - let model = setup_qwen_model(&server).await; - - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create first response with metadata - let response1 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "input": [{ - "role": "user", - "content": "First message", - "metadata": { - "author_id": "user1", - "author_name": "User One", - "message_id": "msg1" - } - }], - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response1.status_code(), 200); - let response1_obj: api::models::ResponseObject = response1.json(); - - // Create second response with different metadata - let response2 = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "previous_response_id": response1_obj.id, - "input": [{ - "role": "user", - "content": "Second message", - "metadata": { - "author_id": "user2", - "author_name": "User Two", - "message_id": "msg2" - } - }], - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response2.status_code(), 200); - - // List conversation items and verify both messages have their metadata - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items: api::models::ConversationItemList = items_response.json(); - - // Find all user messages - let user_messages: Vec<_> = items - .data - .iter() - .filter_map(|item| match item { - api::models::ConversationItem::Message { - role, - metadata, - content, - .. - } if role == "user" => Some((content, metadata)), - _ => None, - }) - .collect(); - - assert_eq!(user_messages.len(), 2, "Should have 2 user messages"); - - // Verify first message metadata - let first_msg = &user_messages[0]; - let first_metadata = first_msg - .1 - .as_ref() - .expect("First message should have metadata"); - assert_eq!(first_metadata["author_id"], "user1"); - assert_eq!(first_metadata["author_name"], "User One"); - assert_eq!(first_metadata["message_id"], "msg1"); - - // Verify second message metadata - let second_msg = &user_messages[1]; - let second_metadata = second_msg - .1 - .as_ref() - .expect("Second message should have metadata"); - assert_eq!(second_metadata["author_id"], "user2"); - assert_eq!(second_metadata["author_name"], "User Two"); - assert_eq!(second_metadata["message_id"], "msg2"); -} - -/// Test that conversation items preserve metadata from request-level metadata -#[tokio::test] -async fn test_conversation_items_include_request_metadata() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - let model = setup_qwen_model(&server).await; - - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create response with request-level metadata (for simple text input) - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "input": "Simple text with request metadata", - "metadata": { - "author_id": "request_user", - "author_name": "Request User" - }, - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response.status_code(), 200); - let _response_obj: api::models::ResponseObject = response.json(); - - // List conversation items and verify request metadata is included - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items: api::models::ConversationItemList = items_response.json(); - - // Find the user message item - let user_message = items - .data - .iter() - .find(|item| matches!(item, api::models::ConversationItem::Message { role, .. } if role == "user")) - .expect("Should find user message in conversation items"); - - if let api::models::ConversationItem::Message { metadata, .. } = user_message { - let metadata = metadata - .as_ref() - .expect("User message should have metadata from request"); - - // Verify author metadata from request is present - assert_eq!(metadata["author_id"], "request_user"); - assert_eq!(metadata["author_name"], "Request User"); - } else { - panic!("Expected Message item"); - } -} - -/// Test that conversation items without metadata work correctly -#[tokio::test] -async fn test_conversation_items_without_metadata() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - let model = setup_qwen_model(&server).await; - - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create response without any metadata - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "input": [{ - "role": "user", - "content": "Message without metadata" - }], - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response.status_code(), 200); - let _response_obj: api::models::ResponseObject = response.json(); - - // List conversation items - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items: api::models::ConversationItemList = items_response.json(); - - // Find the user message item - let user_message = items - .data - .iter() - .find(|item| matches!(item, api::models::ConversationItem::Message { role, .. } if role == "user")) - .expect("Should find user message in conversation items"); - - if let api::models::ConversationItem::Message { metadata, .. } = user_message { - // Metadata should be None when not provided - assert!( - metadata.is_none(), - "Metadata should be None when not provided, got: {:?}", - metadata - ); - } else { - panic!("Expected Message item"); - } -} - -/// Test that item-level metadata takes precedence over request-level metadata -#[tokio::test] -async fn test_conversation_items_metadata_precedence() { - let (server, _, _mock, _db) = setup_test_server_with_pool().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - let api_key = get_api_key_for_org(&server, org.id).await; - - let model = setup_qwen_model(&server).await; - - // Create a conversation - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation" - })) - .await; - assert_eq!(conversation.status_code(), 201); - let conversation: api::models::ConversationObject = conversation.json(); - - // Create a response with both request-level and item-level metadata - // Item-level metadata should take precedence - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "conversation": { - "id": conversation.id, - }, - "metadata": { - "author_id": "request-author-id", - "author_name": "Request Author", - "request_field": "request-value" - }, - "input": [{ - "role": "user", - "content": "Hello", - "metadata": { - "author_id": "item-author-id", - "author_name": "Item Author", - "item_field": "item-value" - } - }], - "stream": false, - "max_output_tokens": 10 - })) - .await; - - assert_eq!(response.status_code(), 200); - let _response_obj: api::models::ResponseObject = response.json(); - - // List conversation items and verify metadata precedence - let items_response = server - .get(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!(items_response.status_code(), 200); - let items: api::models::ConversationItemList = items_response.json(); - - // Find the user message - let user_message = items - .data - .iter() - .find(|item| { - if let api::models::ConversationItem::Message { role, .. } = item { - role == "user" - } else { - false - } - }) - .expect("Should find user message in conversation items"); - - if let api::models::ConversationItem::Message { metadata, .. } = user_message { - // Item-level metadata should take precedence - let metadata = metadata.as_ref().expect("Metadata should be present"); - - // Item-level fields should be present - assert_eq!( - metadata["author_id"], "item-author-id", - "Item-level author_id should take precedence" - ); - assert_eq!( - metadata["author_name"], "Item Author", - "Item-level author_name should take precedence" - ); - assert_eq!( - metadata["item_field"], "item-value", - "Item-level field should be present" - ); - - // Request-level fields that don't conflict should NOT be present - // (because item-level metadata replaces the entire metadata object) - assert!( - metadata.get("request_field").is_none(), - "Request-level field should not be present when item-level metadata is provided" - ); - } else { - panic!("Expected Message item"); - } + assert!(error.error.message.contains("metadata is too large")); } diff --git a/crates/api/tests/e2e_all/org_system_prompt.rs b/crates/api/tests/e2e_all/org_system_prompt.rs index 2098d6e53..11e384ebd 100644 --- a/crates/api/tests/e2e_all/org_system_prompt.rs +++ b/crates/api/tests/e2e_all/org_system_prompt.rs @@ -137,9 +137,9 @@ async fn test_system_prompt_isolation() { ); } -/// Test that system prompt is applied in conversation responses +/// Test that system prompt is applied to a stateless Responses request. #[tokio::test] -async fn test_system_prompt_integration_with_responses() { +async fn test_system_prompt_integration_with_stateless_responses() { let server = setup_test_server().await; let org = setup_org_with_credits(&server, 10000000000i64).await; let api_key = get_api_key_for_org(&server, org.id.clone()).await; @@ -157,15 +157,6 @@ async fn test_system_prompt_integration_with_responses() { })) .await; - // Create conversation and response - let conversation = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .add_header("User-Agent", MOCK_USER_AGENT) - .json(&json!({ "metadata": { "source": "test" } })) - .await - .json::(); - let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) @@ -173,7 +164,7 @@ async fn test_system_prompt_integration_with_responses() { .json(&json!({ "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", "input": "Hello", - "conversation": conversation.id, + "store": false, "stream": false })) .await; diff --git a/crates/api/tests/e2e_all/response_signature_verification.rs b/crates/api/tests/e2e_all/response_signature_verification.rs deleted file mode 100644 index a2e29f757..000000000 --- a/crates/api/tests/e2e_all/response_signature_verification.rs +++ /dev/null @@ -1,450 +0,0 @@ -// Import common test utilities - -use crate::common::*; - -// ============================================ -// Response Stream Signature Verification Tests -// ============================================ - -#[tokio::test] -async fn test_streaming_response_signature_verification() { - let server = setup_test_server().await; - setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10000000000i64).await; // $10.00 USD - println!("Created organization: {}", org.id); - - let api_key = get_api_key_for_org(&server, org.id).await; - - // Use a simple, consistent model for testing - let model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"; - - // Step 1: Create a conversation - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "A test conversation for signature verification" - })) - .await; - - assert_eq!( - conversation_response.status_code(), - 201, - "Failed to create conversation" - ); - - let conversation = conversation_response.json::(); - println!("Created conversation: {}", conversation.id); - - // Step 2: Construct request body with streaming enabled - let request_body = serde_json::json!({ - "conversation": { - "id": conversation.id, - }, - "input": "Respond with only two words.", - "temperature": 0.7, - "max_output_tokens": 50, - "stream": true, - "model": model_name, - "nonce": 42, - "signing_algo": "ecdsa" - }); - - println!("\n=== Request Body ==="); - println!("{}", serde_json::to_string_pretty(&request_body).unwrap()); - - // Step 3: Compute expected request hash - let request_json = serde_json::to_string(&request_body).expect("Failed to serialize request"); - let expected_request_hash = compute_sha256(&request_json); - println!("\n=== Expected Request Hash ==="); - println!("Request JSON: {request_json}"); - println!("Expected hash: {expected_request_hash}"); - - // Step 4: Make streaming request and capture raw response - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&request_body) - .await; - - println!("\n=== Response Status ==="); - println!("Status: {}", response.status_code()); - assert_eq!( - response.status_code(), - 200, - "Streaming request should succeed" - ); - - // Capture the complete raw response text (SSE format) - let response_text = response.text(); - println!("=== Raw Streaming Response ==="); - println!("{response_text}"); - - // Step 5: Parse streaming response to extract response_id and verify structure - let mut response_id: Option = None; - let mut content = String::new(); - - println!("=== Parsing SSE Stream ==="); - for line_chunk in response_text.split("\n\n") { - if line_chunk.trim().is_empty() { - continue; - } - - let mut event_type = ""; - let mut event_data = ""; - - for line in line_chunk.lines() { - if let Some(event_name) = line.strip_prefix("event: ") { - event_type = event_name; - } else if let Some(data) = line.strip_prefix("data: ") { - event_data = data; - } - } - - if !event_data.is_empty() { - if let Ok(event_json) = serde_json::from_str::(event_data) { - match event_type { - "response.created" => { - // Extract response_id from the first event - if response_id.is_none() { - if let Some(response_obj) = event_json.get("response") { - if let Some(id) = response_obj.get("id").and_then(|v| v.as_str()) { - response_id = Some(id.to_string()); - println!("Extracted response_id: {id}"); - } - } - } - } - "response.output_text.delta" => { - // Accumulate content deltas - if let Some(delta) = event_json.get("delta").and_then(|v| v.as_str()) { - content.push_str(delta); - } - } - "response.completed" => { - println!("Stream completed with response.completed event"); - } - _ => { - // Other events like response.in_progress, response.output_item.added, etc. - } - } - } - } - } - - let response_id = response_id.expect("Should have extracted response_id from stream"); - println!("Accumulated content: '{content}'"); - assert!(!content.is_empty(), "Should have received some content"); - - // Step 6: Compute expected response hash from the complete raw response - let expected_response_hash = compute_sha256(&response_text); - println!("\n=== Expected Response Hash ==="); - println!("Expected hash: {expected_response_hash}"); - - // Wait for signature to be stored asynchronously - println!("\n=== Waiting for Signature Storage ==="); - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - - // Step 7: Query signature API (using response_id instead of chat_id) - println!("\n=== Querying Signature API ==="); - let signature_response = server - .get(format!("/v1/signature/{response_id}?model={model_name}&signing_algo=ecdsa").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - println!("Signature API status: {}", signature_response.status_code()); - assert_eq!( - signature_response.status_code(), - 200, - "Signature API should return successfully" - ); - - let signature_json = signature_response.json::(); - println!( - "Signature response: {}", - serde_json::to_string_pretty(&signature_json).unwrap() - ); - - // Step 8: Parse signature text field (format: "request_hash:response_hash") - let signature_text = signature_json - .get("text") - .and_then(|v| v.as_str()) - .expect("Signature response should have 'text' field"); - - println!("\n=== Parsing Signature Text ==="); - println!("Signature text: {signature_text}"); - - let hash_parts: Vec<&str> = signature_text.split(':').collect(); - assert_eq!( - hash_parts.len(), - 2, - "Signature text should contain two hashes separated by ':'" - ); - - let actual_request_hash = hash_parts[0]; - let actual_response_hash = hash_parts[1]; - - println!("Actual request hash: {actual_request_hash}"); - println!("Actual response hash: {actual_response_hash}"); - - // Step 9: Critical Assertions - Verify hashes match - println!("\n=== Hash Verification ==="); - - println!("\nRequest Hash Comparison:"); - println!(" Expected: {expected_request_hash}"); - println!(" Actual: {actual_request_hash}"); - - assert_eq!( - expected_request_hash, actual_request_hash, - "\n\n❌ REQUEST HASH MISMATCH!\n\ - Expected: {expected_request_hash}\n\ - Actual: {actual_request_hash}\n\n\ - This means the signature API is not using the correct request body for hashing.\n\ - The signature cannot be verified correctly.\n" - ); - - println!("\nResponse Hash Comparison:"); - println!(" Expected: {expected_response_hash}"); - println!(" Actual: {actual_response_hash}"); - - assert_eq!( - expected_response_hash, actual_response_hash, - "\n\n❌ RESPONSE HASH MISMATCH!\n\ - Expected: {expected_response_hash}\n\ - Actual: {actual_response_hash}\n\n\ - This means the signature API is not using the correct streaming response body for hashing.\n\ - The signature cannot be verified correctly.\n" - ); - - println!("\n✅ All hash verifications passed!"); - println!("The streaming response signatures are correctly computed."); - - // Verify the signature itself is present - let signature = signature_json - .get("signature") - .and_then(|v| v.as_str()) - .expect("Should have signature field"); - assert!(!signature.is_empty(), "Signature should not be empty"); - assert!( - signature.starts_with("0x"), - "Signature should be hex-encoded" - ); - - let signing_address = signature_json - .get("signing_address") - .and_then(|v| v.as_str()) - .expect("Should have signing_address field"); - assert!( - !signing_address.is_empty(), - "Signing address should not be empty" - ); - - let signing_algo = signature_json - .get("signing_algo") - .and_then(|v| v.as_str()) - .expect("Should have signing_algo field"); - assert_eq!(signing_algo, "ecdsa", "Should use ECDSA signing algorithm"); - - // Step 10: Verify the ECDSA signature cryptographically - println!("\n=== ECDSA Signature Verification ==="); - println!("Verifying signature for message: {signature_text}"); - println!( - "Signature: {}", - &signature[..std::cmp::min(20, signature.len())] - ); - println!("Signing address: {signing_address}"); - - let is_valid = - crate::common::verify_ecdsa_signature(signature_text, signature, signing_address); - assert!( - is_valid, - "\n\n❌ ECDSA SIGNATURE VERIFICATION FAILED!\n\ - The signature could not be verified against the message and signing address.\n\ - This means the signature is cryptographically invalid.\n" - ); - - println!("✅ ECDSA signature is cryptographically valid!"); - println!("✅ Recovered public key matches signing address!"); - - println!("\n=== Test Summary ==="); - println!("✅ Streaming response request succeeded"); - println!("✅ Response ID extracted: {response_id}"); - println!("✅ Content received: {} chars", content.len()); - println!("✅ Signature stored and retrieved"); - println!("✅ Request hash matches: {expected_request_hash}"); - println!("✅ Response hash matches: {expected_response_hash}"); - println!( - "✅ Signature is present: {}", - &signature[..std::cmp::min(20, signature.len())] - ); - println!("✅ Signing address: {signing_address}"); - println!("✅ Signing algorithm: {signing_algo}"); - println!("✅ ECDSA signature cryptographically verified"); -} - -// ============================================ -// Non-Streaming Response Signature Tests -// ============================================ - -#[tokio::test] -async fn test_non_streaming_response_signature_verification() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - let model_name = "Qwen/Qwen3-30B-A3B-Instruct-2507"; - - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "name": "Test Conversation", - "description": "Test non-streaming signatures" - })) - .await; - - assert_eq!(conversation_response.status_code(), 201); - let conversation = conversation_response.json::(); - - let request_body = serde_json::json!({ - "conversation": { "id": conversation.id }, - "input": "Respond with two words.", - "stream": false, - "model": model_name, - "nonce": 42 - }); - - let request_json = serde_json::to_string(&request_body).unwrap(); - let expected_request_hash = compute_sha256(&request_json); - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&request_body) - .await; - - assert_eq!( - response.status_code(), - 200, - "POST /v1/responses should succeed" - ); - - let response_text = response.text(); - let response_json: serde_json::Value = - serde_json::from_str(&response_text).expect("Response must be valid JSON"); - let response_id = response_json - .get("id") - .and_then(|v| v.as_str()) - .expect("Response must have id field") - .to_string(); - - let expected_response_hash = compute_sha256(&response_text); - - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - - // Test ECDSA signature - let ecdsa_response = server - .get(format!("/v1/signature/{response_id}?model={model_name}&signing_algo=ecdsa").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!( - ecdsa_response.status_code(), - 200, - "Signature should be stored for non-streaming response" - ); - - let ecdsa_json = ecdsa_response.json::(); - let signature_text = ecdsa_json - .get("text") - .and_then(|v| v.as_str()) - .expect("Signature response must have text field"); - - let hash_parts: Vec<&str> = signature_text.split(':').collect(); - assert_eq!( - hash_parts.len(), - 2, - "Signature text should be request_hash:response_hash" - ); - - assert_eq!( - hash_parts[0], expected_request_hash, - "Request hash must match computed value" - ); - assert_eq!( - hash_parts[1], expected_response_hash, - "Response hash must match computed value" - ); - - let signature = ecdsa_json - .get("signature") - .and_then(|v| v.as_str()) - .expect("Must have signature field"); - assert!(!signature.is_empty(), "Signature cannot be empty"); - assert!(signature.starts_with("0x"), "Signature must be hex-encoded"); - - let signing_address = ecdsa_json - .get("signing_address") - .and_then(|v| v.as_str()) - .expect("Must have signing_address field"); - assert!( - !signing_address.is_empty(), - "Signing address cannot be empty" - ); - - let signing_algo = ecdsa_json - .get("signing_algo") - .and_then(|v| v.as_str()) - .expect("Must have signing_algo field"); - assert_eq!(signing_algo, "ecdsa", "Should use ECDSA algorithm"); - - let is_valid = - crate::common::verify_ecdsa_signature(signature_text, signature, signing_address); - assert!(is_valid, "ECDSA signature must be cryptographically valid"); - - // Test ED25519 signature uses same format - let ed25519_response = server - .get( - format!("/v1/signature/{response_id}?model={model_name}&signing_algo=ed25519").as_str(), - ) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - - assert_eq!( - ed25519_response.status_code(), - 200, - "ED25519 signature should be available" - ); - - let ed25519_json = ed25519_response.json::(); - let ed25519_text = ed25519_json - .get("text") - .and_then(|v| v.as_str()) - .expect("Must have ED25519 signature text"); - - assert_eq!( - signature_text, ed25519_text, - "Both algorithms must produce same signature text format" - ); - - // Verify the ED25519 signature cryptographically - let ed25519_signature = ed25519_json - .get("signature") - .and_then(|v| v.as_str()) - .expect("Should have ED25519 signature field"); - - let ed25519_signing_address = ed25519_json - .get("signing_address") - .and_then(|v| v.as_str()) - .expect("Should have ED25519 signing_address field"); - - let is_valid = crate::common::verify_ed25519_signature( - ed25519_text, - ed25519_signature, - ed25519_signing_address, - ); - assert!( - is_valid, - "ED25519 signature must be cryptographically valid" - ); -} diff --git a/crates/api/tests/e2e_all/responses_stateless.rs b/crates/api/tests/e2e_all/responses_stateless.rs new file mode 100644 index 000000000..3eaeeed7d --- /dev/null +++ b/crates/api/tests/e2e_all/responses_stateless.rs @@ -0,0 +1,99 @@ +//! E2E boundary coverage for the stateless Responses API. + +use crate::common::*; +use axum::http::Method; + +fn assert_response_history_is_gone(response: axum_test::TestResponse) { + assert_eq!(response.status_code(), 410); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + + let error = response.json::(); + assert_eq!(error.error.r#type, "gone"); + assert!(error.error.message.contains("stateless")); +} + +#[tokio::test] +async fn retired_response_history_routes_require_authentication_then_return_gone() { + let server = setup_test_server().await; + + assert_eq!( + server.get("/v1/responses/resp_example").await.status_code(), + 401 + ); + + let (api_key, _) = create_org_and_api_key(&server).await; + let routes = [ + (Method::GET, "/v1/responses/resp_example"), + (Method::DELETE, "/v1/responses/resp_example"), + (Method::POST, "/v1/responses/resp_example/cancel"), + (Method::GET, "/v1/responses/resp_example/input_items"), + ]; + + for (method, path) in routes { + assert_response_history_is_gone( + server + .method(method.clone(), path) + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); + } +} + +#[tokio::test] +async fn stateless_responses_reject_persistent_fields() { + let server = setup_test_server().await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let requests = [ + serde_json::json!({"model": "test-model", "input": "hello", "store": true}), + serde_json::json!({ + "model": "test-model", + "input": "hello", + "store": false, + "conversation": "conv_example" + }), + serde_json::json!({ + "model": "test-model", + "input": "hello", + "store": false, + "previous_response_id": "resp_example" + }), + serde_json::json!({ + "model": "test-model", + "input": "hello", + "store": false, + "background": true + }), + serde_json::json!({ + "model": "test-model", + "store": false, + "input": [{ + "role": "user", + "content": [{"type": "input_file", "file_id": "file_example"}] + }] + }), + ]; + + for request in requests { + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&request) + .await; + + assert_eq!( + response.status_code(), + 400, + "request {request} must be rejected" + ); + let error = response.json::(); + assert_eq!(error.error.r#type, "invalid_request_error"); + } +} diff --git a/crates/api/tests/e2e_all/usage_responses.rs b/crates/api/tests/e2e_all/usage_responses.rs index 1abd8507c..774a87727 100644 --- a/crates/api/tests/e2e_all/usage_responses.rs +++ b/crates/api/tests/e2e_all/usage_responses.rs @@ -6,23 +6,6 @@ use crate::common::*; use serde_json::json; use services::usage::compute_token_cost; -/// Helper: create a simple conversation for the given API key. -async fn create_conversation( - server: &axum_test::TestServer, - api_key: String, -) -> api::models::ConversationObject { - let response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "name": "Test Conversation (usage)", - "description": "Conversation for responses usage tests" - })) - .await; - assert_eq!(response.status_code(), 201); - response.json::() -} - /// Non-streaming Responses API: set mock cache_tokens based on provider token estimate, /// then verify: /// - ResponseObject.usage.input_tokens_details.cached_tokens equals that cache_tokens @@ -49,18 +32,15 @@ async fn test_responses_non_stream_records_cache_usage_in_history() { ) .await; - // Create a conversation and then a non-streaming response - let conversation = create_conversation(&server, api_key.clone()).await; - let resp = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&json!({ - "conversation": { "id": conversation.id }, "input": message, "temperature": 0.7, "max_output_tokens": 64, "stream": false, + "store": false, "model": E2E_QWEN_MODEL_NAME })) .await; @@ -161,17 +141,15 @@ async fn test_responses_stream_records_cache_usage_in_history() { ) .await; - let conversation = create_conversation(&server, api_key.clone()).await; - let resp = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&json!({ - "conversation": { "id": conversation.id }, "input": message, "temperature": 0.7, "max_output_tokens": 64, "stream": true, + "store": false, "model": E2E_QWEN_MODEL_NAME })) .await; @@ -183,7 +161,7 @@ async fn test_responses_stream_records_cache_usage_in_history() { resp.text() ); - // Drain SSE stream: parse as Value then "response" -> api::models::ResponseObject (same as e2e_conversations create_response_stream) + // Drain SSE stream and parse the completed response payload. let sse_text = resp.text(); let mut completed_response: Option = None; diff --git a/crates/api/tests/e2e_all/web_search_citations.rs b/crates/api/tests/e2e_all/web_search_citations.rs index 067b4cca4..73079e34a 100644 --- a/crates/api/tests/e2e_all/web_search_citations.rs +++ b/crates/api/tests/e2e_all/web_search_citations.rs @@ -125,36 +125,15 @@ async fn test_non_streaming_web_search_with_citations() { let api_key = get_api_key_for_org(&server, org.id).await; let model = setup_glm_model(&server).await; - // Create a conversation - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "metadata": { - "title": "Non-Streaming Web Search Citation Test" - } - })) - .await; - - assert_eq!(conversation_response.status_code(), 201); - - let conversation_data = conversation_response.json::(); - let conversation_id = conversation_data - .get("id") - .and_then(|v| v.as_str()) - .expect("Conversation ID should be present"); - - println!("✓ Created conversation: {conversation_id}"); - // Create non-streaming response with web search // Use a specific query that requires current information and citations let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&json!({ - "conversation": conversation_id, "model": model, "input": "What is the weather in San Francisco today? Search the web for current weather conditions.", + "store": false, "stream": false, "max_output_tokens": 512, "temperature": 0.7, @@ -275,35 +254,14 @@ async fn test_streaming_web_search_with_citations() { let api_key = get_api_key_for_org(&server, org.id).await; let model = setup_glm_model(&server).await; - // Create a conversation - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "metadata": { - "title": "Streaming Web Search Citation Test" - } - })) - .await; - - assert_eq!(conversation_response.status_code(), 201); - - let conversation_data = conversation_response.json::(); - let conversation_id = conversation_data - .get("id") - .and_then(|v| v.as_str()) - .expect("Conversation ID should be present"); - - println!("✓ Created conversation: {conversation_id}"); - // Create streaming response with web search let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&json!({ - "conversation": conversation_id, "model": model, "input": "What is the current weather in New York City? Search the web for real-time weather conditions.", + "store": false, "stream": true, "max_output_tokens": 512, "temperature": 0.7, @@ -576,35 +534,14 @@ async fn capture_streaming_citations_to_file() { let api_key = get_api_key_for_org(&server, org.id).await; let model = setup_glm_model(&server).await; - // Create a conversation - let conversation_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "metadata": { - "title": "Streaming Citations Capture Test" - } - })) - .await; - - assert_eq!(conversation_response.status_code(), 201); - - let conversation_data = conversation_response.json::(); - let conversation_id = conversation_data - .get("id") - .and_then(|v| v.as_str()) - .expect("Conversation ID should be present"); - - println!("✓ Created conversation: {conversation_id}"); - // Create streaming response with web search let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&json!({ - "conversation": conversation_id, "model": model, "input": "What are the latest developments in AI? Search the web and provide current information with citations.", + "store": false, "stream": true, "max_output_tokens": 256, "temperature": 0.7, From 9e2d9d60848dc4a34337e7fb74c5afff2fac6ed7 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:15:12 +0800 Subject: [PATCH 05/31] test: cover stateless MCP execution --- crates/api/tests/e2e_all/mcp.rs | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/crates/api/tests/e2e_all/mcp.rs b/crates/api/tests/e2e_all/mcp.rs index 17b8f43b5..2e5e892e2 100644 --- a/crates/api/tests/e2e_all/mcp.rs +++ b/crates/api/tests/e2e_all/mcp.rs @@ -5,6 +5,122 @@ //! client to resume later. use crate::common::*; +use inference_providers::mock::{RequestMatcher, ResponseTemplate, ToolCall}; +use services::responses::models::McpDiscoveredTool; +use services::responses::tools::{MockMcpClient, MockMcpClientFactory}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +#[tokio::test] +async fn mcp_tools_without_approval_complete_in_one_stateless_request() { + let list_tools_calls = Arc::new(AtomicUsize::new(0)); + let tool_calls = Arc::new(AtomicUsize::new(0)); + let list_tools_calls_for_factory = list_tools_calls.clone(); + let tool_calls_for_factory = tool_calls.clone(); + + let mut mock_factory = MockMcpClientFactory::new(); + mock_factory + .expect_create_client() + .withf(|url: &str, _| url == "https://example.com/mcp") + .returning(move |_, _| { + let list_tools_calls = list_tools_calls_for_factory.clone(); + let tool_calls = tool_calls_for_factory.clone(); + let mut client = MockMcpClient::new(); + + client.expect_list_tools().returning(move || { + list_tools_calls.fetch_add(1, Ordering::SeqCst); + Ok(vec![McpDiscoveredTool { + name: "get_weather".to_string(), + description: Some("Get weather for a location".to_string()), + input_schema: Some(serde_json::json!({ + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"] + })), + annotations: None, + }]) + }); + client + .expect_call_tool() + .withf(|name: &str, arguments| { + name == "get_weather" && arguments["location"] == "San Francisco" + }) + .returning(move |_, _| { + tool_calls.fetch_add(1, Ordering::SeqCst); + Ok("Weather in San Francisco: Sunny, 72°F".to_string()) + }); + + Ok(Box::new(client) as Box) + }); + + let (server, _pool, mock) = setup_test_server_with_mcp_factory(Arc::new(mock_factory)).await; + let model = setup_qwen_model(&server).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let prompt = "What's the weather in San Francisco?"; + mock.when(RequestMatcher::PromptWithTools { + prompt: mock_prompts::build_prompt(prompt), + tool_names: vec!["weather:get_weather".to_string()], + }) + .respond_with( + ResponseTemplate::new("").with_tool_calls(vec![ToolCall::new( + "weather:get_weather", + serde_json::json!({"location": "San Francisco"}).to_string(), + )]), + ) + .await; + mock.set_default_response(ResponseTemplate::new( + "The weather in San Francisco is sunny and 72°F.", + )) + .await; + + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model, + "input": prompt, + "store": false, + "stream": false, + "tools": [{ + "type": "mcp", + "server_label": "weather", + "server_url": "https://example.com/mcp", + "require_approval": "never" + }] + })) + .await; + + assert_eq!( + response.status_code(), + 200, + "stateless MCP request failed: {}", + response.text() + ); + let response = response.json::(); + assert_eq!(response["status"], "completed"); + assert_eq!(list_tools_calls.load(Ordering::SeqCst), 1); + assert_eq!(tool_calls.load(Ordering::SeqCst), 1); + + let output = response["output"] + .as_array() + .expect("response output should be an array"); + let discovered = output + .iter() + .find(|item| item["type"] == "mcp_list_tools") + .expect("MCP tools should be discovered during the request"); + assert_eq!(discovered["server_label"], "weather"); + assert_eq!(discovered["tools"][0]["name"], "get_weather"); + assert!( + output + .iter() + .all(|item| item["type"] != "mcp_approval_request"), + "require_approval=never must not produce a resumable approval request" + ); +} #[tokio::test] async fn mcp_tools_requiring_approval_are_rejected_by_stateless_responses() { From e8708d83f13c9b6e83f923410caee827e0dfd410 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:59:00 +0800 Subject: [PATCH 06/31] fix: complete stateless Responses transition --- crates/api/src/lib.rs | 55 +++ crates/api/tests/e2e_all/cross_workspace.rs | 465 -------------------- crates/api/tests/e2e_all/main.rs | 1 - crates/api/tests/e2e_all/mcp.rs | 199 --------- crates/api/tests/e2e_all/repositories.rs | 199 --------- 5 files changed, 55 insertions(+), 864 deletions(-) delete mode 100644 crates/api/tests/e2e_all/cross_workspace.rs diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 7ae315c44..307dd1003 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1704,6 +1704,10 @@ pub fn build_response_routes( Router::new() .merge(inference_routes) .merge(retired_history_routes) + // Responses can contain user-provided prompts and model output. Apply + // this outside the route groups so extractor, authentication, and + // rate-limit rejections are no-store too. + .layer(map_response(no_store_response)) } /// Build explicit not-implemented handlers for recognized OpenAI-compatible @@ -2053,6 +2057,16 @@ async fn cache_control_on_success( res } +/// Force every response for a request-scoped Responses route to bypass shared +/// and browser caches. Unlike public catalog routes, even error responses may +/// include request-specific details from validation or authentication. +async fn no_store_response(mut response: Response) -> Response { + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + // Type aliases for `cache_control_layer`'s return type. They name the // otherwise-unnameable function-pointer + future combination so the helper's // signature stays readable (and satisfies clippy::type_complexity). @@ -3060,6 +3074,47 @@ mod tests { use axum::routing::get; use tower::ServiceExt; + #[tokio::test] + async fn no_store_response_layer_covers_extractor_rejections() { + #[derive(serde::Deserialize)] + struct RequestPayload { + #[allow(dead_code)] + model: String, + } + + async fn handler( + crate::routes::extractors::OpenAiJson(_): crate::routes::extractors::OpenAiJson< + RequestPayload, + >, + ) -> StatusCode { + StatusCode::OK + } + + let app = Router::new() + .route("/responses", axum::routing::post(handler)) + .layer(map_response(no_store_response)); + let response = app + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/responses") + .header("content-type", "application/json") + .body(Body::from("{not json")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + ); + } + fn cache_test_app() -> Router { async fn ok_handler() -> impl IntoResponse { (StatusCode::OK, "ok") diff --git a/crates/api/tests/e2e_all/cross_workspace.rs b/crates/api/tests/e2e_all/cross_workspace.rs deleted file mode 100644 index 01784701c..000000000 --- a/crates/api/tests/e2e_all/cross_workspace.rs +++ /dev/null @@ -1,465 +0,0 @@ -// Cross-workspace access control tests (issue nearai/infra#190). -// -// Every direct-object operation on conversations and responses must be -// constrained to the caller's workspace. Unknown and foreign IDs must be -// indistinguishable (same non-enumerating 404), and failed cross-workspace -// attempts must never mutate or leak the owner's data. -// -// Privacy note: these tests only assert on IDs, item counts, and status -// codes — never on conversation contents. - -use crate::common::*; - -async fn create_conversation( - server: &axum_test::TestServer, - api_key: &str, -) -> api::models::ConversationObject { - let response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(response.status_code(), 201); - response.json::() -} - -/// Backfills one user message item and returns its item ID. -async fn add_item( - server: &axum_test::TestServer, - conversation_id: &str, - api_key: &str, - text: &str, -) -> String { - let response = server - .post(format!("/v1/conversations/{conversation_id}/items").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [{ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": text}] - }] - })) - .await; - assert_eq!(response.status_code(), 200); - response - .json::() - .first_id -} - -async fn count_items( - server: &axum_test::TestServer, - conversation_id: &str, - api_key: &str, -) -> usize { - let response = server - .get(format!("/v1/conversations/{conversation_id}/items").as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; - assert_eq!(response.status_code(), 200); - response - .json::() - .data - .len() -} - -/// Cross-workspace conversation operations must all return the same -/// non-enumerating 404 as an unknown conversation ID, and must not change the -/// owner's data. -#[tokio::test] -async fn test_cross_workspace_conversation_operations_denied() { - let server = setup_test_server().await; - let (key_a, _) = create_org_and_api_key(&server).await; - let (key_b, _) = create_org_and_api_key(&server).await; - - let conv_a = create_conversation(&server, &key_a).await; - add_item(&server, &conv_a.id, &key_a, "hello").await; - add_item(&server, &conv_a.id, &key_a, "world").await; - assert_eq!(count_items(&server, &conv_a.id, &key_a).await, 2); - - let unknown_conv = format!("conv_{}", uuid::Uuid::new_v4().simple()); - - // GET conversation: foreign and unknown IDs return identical 404s. - let foreign_get = server - .get(format!("/v1/conversations/{}", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - let unknown_get = server - .get(format!("/v1/conversations/{unknown_conv}").as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_get.status_code(), 404); - assert_eq!(unknown_get.status_code(), 404); - assert_eq!( - foreign_get.text(), - unknown_get.text(), - "foreign and unknown conversation 404 bodies must be identical" - ); - - // GET items: foreign and unknown IDs return identical 404s. - let foreign_items = server - .get(format!("/v1/conversations/{}/items", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - let unknown_items = server - .get(format!("/v1/conversations/{unknown_conv}/items").as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_items.status_code(), 404); - assert_eq!(unknown_items.status_code(), 404); - assert_eq!( - foreign_items.text(), - unknown_items.text(), - "foreign and unknown conversation-items 404 bodies must be identical" - ); - - // POST items (backfill) into a foreign conversation: 404, nothing created. - let foreign_create_items = server - .post(format!("/v1/conversations/{}/items", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "items": [{ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "injected"}] - }] - })) - .await; - assert_eq!(foreign_create_items.status_code(), 404); - - // Update metadata: 404. - let foreign_update = server - .post(format!("/v1/conversations/{}", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({"metadata": {"title": "hijacked"}})) - .await; - assert_eq!(foreign_update.status_code(), 404); - - // Pin / unpin: 404. - let foreign_pin = server - .post(format!("/v1/conversations/{}/pin", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_pin.status_code(), 404); - let foreign_unpin = server - .delete(format!("/v1/conversations/{}/pin", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_unpin.status_code(), 404); - - // Archive / unarchive: 404. - let foreign_archive = server - .post(format!("/v1/conversations/{}/archive", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_archive.status_code(), 404); - let foreign_unarchive = server - .delete(format!("/v1/conversations/{}/archive", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_unarchive.status_code(), 404); - - // Clone: 404 (no copy of the foreign conversation may be created). - let foreign_clone = server - .post(format!("/v1/conversations/{}/clone", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_clone.status_code(), 404); - - // Delete: 404, and the owner's conversation must survive. - let foreign_delete = server - .delete(format!("/v1/conversations/{}", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_delete.status_code(), 404); - - // Batch endpoint reports the foreign conversation as missing. - let foreign_batch = server - .post("/v1/conversations/batch") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({"ids": [conv_a.id]})) - .await; - assert_eq!(foreign_batch.status_code(), 200); - let batch = foreign_batch.json::(); - assert!(batch.data.is_empty(), "batch must not return foreign data"); - assert_eq!(batch.missing_ids, vec![conv_a.id.clone()]); - - // Owner's view is completely unchanged after all foreign attempts. - let owner_get = server - .get(format!("/v1/conversations/{}", conv_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_a}")) - .await; - assert_eq!(owner_get.status_code(), 200); - let owner_conv = owner_get.json::(); - let metadata = owner_conv.metadata.as_object().unwrap(); - assert!(!metadata.contains_key("pinned_at"), "must not be pinned"); - assert!( - !metadata.contains_key("archived_at"), - "must not be archived" - ); - assert_eq!( - metadata.get("title").and_then(|v| v.as_str()), - None, - "metadata must not be updated cross-workspace" - ); - assert_eq!( - count_items(&server, &conv_a.id, &key_a).await, - 2, - "item count must be unchanged after cross-workspace attempts" - ); - - // Unauthenticated requests are rejected with 401. - let unauthenticated = server - .get(format!("/v1/conversations/{}/items", conv_a.id).as_str()) - .await; - assert_eq!(unauthenticated.status_code(), 401); -} - -/// The `after` pagination cursor must belong to the same conversation and -/// workspace; foreign and unknown cursors are rejected identically. -#[tokio::test] -async fn test_conversation_items_pagination_cursor_scoping() { - let server = setup_test_server().await; - let (key_a, _) = create_org_and_api_key(&server).await; - let (key_b, _) = create_org_and_api_key(&server).await; - - let conv_a = create_conversation(&server, &key_a).await; - let first_item_a = add_item(&server, &conv_a.id, &key_a, "one").await; - add_item(&server, &conv_a.id, &key_a, "two").await; - add_item(&server, &conv_a.id, &key_a, "three").await; - - let conv_b = create_conversation(&server, &key_b).await; - let item_b = add_item(&server, &conv_b.id, &key_b, "other").await; - - // A cursor from the same conversation works. - let own_cursor = server - .get( - format!( - "/v1/conversations/{}/items?after={}", - conv_a.id, first_item_a - ) - .as_str(), - ) - .add_header("Authorization", format!("Bearer {key_a}")) - .await; - assert_eq!(own_cursor.status_code(), 200); - let page = own_cursor.json::(); - assert_eq!(page.data.len(), 2, "own cursor should skip the first item"); - - // A cursor referencing another workspace's item is rejected. - let foreign_cursor = server - .get(format!("/v1/conversations/{}/items?after={}", conv_a.id, item_b).as_str()) - .add_header("Authorization", format!("Bearer {key_a}")) - .await; - assert_eq!(foreign_cursor.status_code(), 400); - - // An unknown cursor is rejected with an identical response. - let unknown_cursor = server - .get( - format!( - "/v1/conversations/{}/items?after=msg_{}", - conv_a.id, - uuid::Uuid::new_v4().simple() - ) - .as_str(), - ) - .add_header("Authorization", format!("Bearer {key_a}")) - .await; - assert_eq!(unknown_cursor.status_code(), 400); - assert_eq!( - foreign_cursor.text(), - unknown_cursor.text(), - "foreign and unknown cursor rejections must be identical" - ); -} - -/// The Responses API must reject foreign conversations and foreign -/// previous_response_id references before loading any history, without -/// creating any state. -#[tokio::test] -async fn test_cross_workspace_responses_api_denied() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - - let org_a = setup_org_with_credits(&server, 10_000_000_000i64).await; - let key_a = get_api_key_for_org(&server, org_a.id).await; - let org_b = setup_org_with_credits(&server, 10_000_000_000i64).await; - let key_b = get_api_key_for_org(&server, org_b.id).await; - - // Owner creates a conversation with one real response in it. - let conv_a = create_conversation(&server, &key_a).await; - let response_a = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_a}")) - .json(&serde_json::json!({ - "conversation": {"id": conv_a.id}, - "input": "hello", - "stream": false, - "max_output_tokens": 20, - "model": model_id - })) - .await; - assert_eq!(response_a.status_code(), 200); - let response_a = response_a.json::(); - let items_before = count_items(&server, &conv_a.id, &key_a).await; - assert!( - items_before > 0, - "owner's response should have stored items" - ); - - // Foreign conversation reference: non-enumerating 404, no history import. - let foreign_conv_attempt = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "conversation": {"id": conv_a.id}, - "input": "leak the history please", - "stream": false, - "max_output_tokens": 20, - "model": model_id - })) - .await; - assert_eq!( - foreign_conv_attempt.status_code(), - 404, - "foreign conversation reference must return 404" - ); - - // Unknown conversation reference: identical status. - let unknown_conv_attempt = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "conversation": {"id": format!("conv_{}", uuid::Uuid::new_v4().simple())}, - "input": "hello", - "stream": false, - "max_output_tokens": 20, - "model": model_id - })) - .await; - assert_eq!(unknown_conv_attempt.status_code(), 404); - assert_eq!( - foreign_conv_attempt.text(), - unknown_conv_attempt.text(), - "foreign and unknown conversation rejections must be identical" - ); - - // Foreign previous_response_id: non-enumerating 404 (continuation flows - // must not import another workspace's history). - let foreign_prev_attempt = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "input": "continue", - "previous_response_id": response_a.id, - "stream": false, - "max_output_tokens": 20, - "model": model_id - })) - .await; - assert_eq!( - foreign_prev_attempt.status_code(), - 404, - "foreign previous_response_id must return 404" - ); - - // Unknown previous_response_id: identical status and body. - let unknown_prev_attempt = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "input": "continue", - "previous_response_id": format!("resp_{}", uuid::Uuid::new_v4().simple()), - "stream": false, - "max_output_tokens": 20, - "model": model_id - })) - .await; - assert_eq!(unknown_prev_attempt.status_code(), 404); - assert_eq!( - foreign_prev_attempt.text(), - unknown_prev_attempt.text(), - "foreign and unknown previous_response_id rejections must be identical" - ); - - // Cross-workspace input_items listing: 404. - let foreign_input_items = server - .get(format!("/v1/responses/{}/input_items", response_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_input_items.status_code(), 404); - - // None of the failed foreign attempts stored anything in the owner's - // conversation. - let items_after = count_items(&server, &conv_a.id, &key_a).await; - assert_eq!( - items_after, items_before, - "failed cross-workspace attempts must not add items to the owner's conversation" - ); - - // Owner can still continue from its own response (same-workspace flow - // keeps working). - let own_prev = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_a}")) - .json(&serde_json::json!({ - "input": "continue", - "previous_response_id": response_a.id, - "stream": false, - "max_output_tokens": 20, - "model": model_id - })) - .await; - assert_eq!(own_prev.status_code(), 200); -} - -/// Response DELETE/cancel are not implemented: they must be authenticated, -/// return 501 for everyone, and cause no state change anywhere. -#[tokio::test] -async fn test_response_delete_and_cancel_unsupported_but_safe() { - let server = setup_test_server().await; - let model_id = setup_qwen_model(&server).await; - - let org_a = setup_org_with_credits(&server, 10_000_000_000i64).await; - let key_a = get_api_key_for_org(&server, org_a.id).await; - let (key_b, _) = create_org_and_api_key(&server).await; - - let conv_a = create_conversation(&server, &key_a).await; - let response_a = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_a}")) - .json(&serde_json::json!({ - "conversation": {"id": conv_a.id}, - "input": "hello", - "stream": false, - "max_output_tokens": 20, - "model": model_id - })) - .await; - assert_eq!(response_a.status_code(), 200); - let response_a = response_a.json::(); - let items_before = count_items(&server, &conv_a.id, &key_a).await; - - // Unauthenticated delete: 401 from the auth middleware. - let unauthenticated_delete = server - .delete(format!("/v1/responses/{}", response_a.id).as_str()) - .await; - assert_eq!(unauthenticated_delete.status_code(), 401); - - // Foreign authenticated delete: 501 (unimplemented), never 200. - let foreign_delete = server - .delete(format!("/v1/responses/{}", response_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_delete.status_code(), 501); - - // Foreign cancel: 501 as well. - let foreign_cancel = server - .post(format!("/v1/responses/{}/cancel", response_a.id).as_str()) - .add_header("Authorization", format!("Bearer {key_b}")) - .await; - assert_eq!(foreign_cancel.status_code(), 501); - - // No state change: the owner's conversation items are intact. - assert_eq!(count_items(&server, &conv_a.id, &key_a).await, items_before); -} diff --git a/crates/api/tests/e2e_all/main.rs b/crates/api/tests/e2e_all/main.rs index feac167bb..71185a1de 100644 --- a/crates/api/tests/e2e_all/main.rs +++ b/crates/api/tests/e2e_all/main.rs @@ -33,7 +33,6 @@ mod client_disconnect; mod concurrent_limit; mod conversations; mod credit_types; -mod cross_workspace; mod deser_error_envelope; mod duplicate_names; mod embeddings; diff --git a/crates/api/tests/e2e_all/mcp.rs b/crates/api/tests/e2e_all/mcp.rs index 2e5e892e2..a312669d9 100644 --- a/crates/api/tests/e2e_all/mcp.rs +++ b/crates/api/tests/e2e_all/mcp.rs @@ -175,202 +175,3 @@ async fn mcp_approval_continuations_are_rejected_by_stateless_responses() { assert_eq!(error.error.r#type, "invalid_request_error"); assert!(error.error.message.contains("MCP approval continuation")); } - -/// A foreign or unknown MCP approval_request_id must be rejected BEFORE the -/// response row is created (issue nearai/infra#190): no response.created event -/// is emitted and nothing is persisted, so no orphaned in-progress response is -/// left behind, and unknown vs foreign IDs are indistinguishable. -#[tokio::test] -async fn test_mcp_foreign_approval_request_rejected_before_response_creation() { - // Mock MCP server with one tool that always requires approval. - let mut mock_factory = MockMcpClientFactory::new(); - mock_factory - .expect_create_client() - .withf(|url: &str, _| url == "https://example.com/mcp") - .returning(move |_, _| { - let mut client = MockMcpClient::new(); - client.expect_list_tools().returning(move || { - Ok(vec![McpDiscoveredTool { - name: "get_weather".to_string(), - description: Some("Get weather for a location".to_string()), - input_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"] - })), - annotations: None, - }]) - }); - client - .expect_call_tool() - .returning(|_, _| Ok("Weather: Sunny".to_string())); - Ok(Box::new(client) as Box) - }); - - let mcp_factory = Arc::new(mock_factory); - let (server, _pool, mock) = setup_test_server_with_mcp_factory(mcp_factory).await; - setup_qwen_model(&server).await; - - let org_a = setup_org_with_credits(&server, 10_000_000_000i64).await; - let key_a = get_api_key_for_org(&server, org_a.id).await; - let org_b = setup_org_with_credits(&server, 10_000_000_000i64).await; - let key_b = get_api_key_for_org(&server, org_b.id).await; - - let mcp_tool = serde_json::json!({ - "type": "mcp", - "server_label": "weather_server", - "server_url": "https://example.com/mcp", - "require_approval": "always" - }); - - // Org A: trigger a tool call so a real approval request gets stored. - use crate::common::mock_prompts; - let prompt = mock_prompts::build_prompt("What's the weather in San Francisco?"); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - prompt, - )) - .respond_with( - inference_providers::mock::ResponseTemplate::new("").with_tool_calls(vec![ - inference_providers::mock::ToolCall::new( - "weather_server:get_weather", - r#"{"location": "San Francisco"}"#, - ), - ]), - ) - .await; - - let resp_a = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_a}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "input": "What's the weather in San Francisco?", - "stream": false, - "tools": [mcp_tool.clone()] - })) - .await; - assert_eq!( - resp_a.status_code(), - 200, - "org A turn failed: {}", - resp_a.text() - ); - let resp_a_obj = resp_a.json::(); - assert_eq!(resp_a_obj.status, api::models::ResponseStatus::Incomplete); - - let approval_request_id = resp_a_obj - .output - .iter() - .find_map(|item| { - if let api::models::ResponseOutputItem::McpApprovalRequest { id, .. } = item { - Some(id.clone()) - } else { - None - } - }) - .expect("org A should receive an mcp_approval_request"); - - // Org B: non-streaming attempt referencing org A's approval request. - let foreign_attempt = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "input": [{ - "type": "mcp_approval_response", - "approval_request_id": approval_request_id, - "approve": true - }], - "stream": false, - "tools": [mcp_tool.clone()] - })) - .await; - assert_eq!( - foreign_attempt.status_code(), - 404, - "foreign approval_request_id must be a non-enumerating 404: {}", - foreign_attempt.text() - ); - - // Org B: unknown approval id gives the same status (non-enumerating). - let unknown_attempt = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "input": [{ - "type": "mcp_approval_response", - "approval_request_id": format!("mcpr_{}", uuid::Uuid::new_v4().simple()), - "approve": true - }], - "stream": false, - "tools": [mcp_tool.clone()] - })) - .await; - assert_eq!(unknown_attempt.status_code(), 404); - - // Org B: streaming attempt proves the rejection happens BEFORE the - // response row is created: the stream must contain response.failed but - // never response.created (which is emitted right after persistence). - let streaming_attempt = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_b}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "input": [{ - "type": "mcp_approval_response", - "approval_request_id": approval_request_id, - "approve": true - }], - "stream": true, - "tools": [mcp_tool.clone()] - })) - .await; - assert_eq!(streaming_attempt.status_code(), 200, "SSE transport is 200"); - let sse_body = streaming_attempt.text(); - assert!( - !sse_body.contains("response.created"), - "no response may be created for a foreign approval_request_id" - ); - assert!( - sse_body.contains("response.failed"), - "stream must emit response.failed for a foreign approval_request_id" - ); - assert!( - sse_body.contains("\"status_code\":404"), - "failure must carry the non-enumerating 404 status" - ); - - // Org A can still resolve its own approval request afterwards. - let approve_prompt = - mock_prompts::build_prompt("What's the weather in San Francisco? Weather: Sunny"); - mock.when(inference_providers::mock::RequestMatcher::ExactPrompt( - approve_prompt, - )) - .respond_with(inference_providers::mock::ResponseTemplate::new( - "It is sunny in San Francisco.", - )) - .await; - - let own_approval = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {key_a}")) - .json(&serde_json::json!({ - "model": "Qwen/Qwen3-30B-A3B-Instruct-2507", - "previous_response_id": resp_a_obj.id, - "input": [{ - "type": "mcp_approval_response", - "approval_request_id": approval_request_id, - "approve": true - }], - "stream": false, - "tools": [mcp_tool.clone()] - })) - .await; - assert_eq!( - own_approval.status_code(), - 200, - "owner approval must still work: {}", - own_approval.text() - ); -} diff --git a/crates/api/tests/e2e_all/repositories.rs b/crates/api/tests/e2e_all/repositories.rs index de5467ce2..7ec1e300c 100644 --- a/crates/api/tests/e2e_all/repositories.rs +++ b/crates/api/tests/e2e_all/repositories.rs @@ -113,202 +113,3 @@ async fn test_state_replay_protection() { let second = repo.get_and_delete(&state).await.unwrap(); assert!(second.is_none()); } - -// ============================================ -// Response Item Repository workspace scoping (issue nearai/infra#190) -// ============================================ - -mod response_item_workspace_scoping { - use crate::common::*; - use database::PgResponseItemsRepository; - use services::conversations::models::ConversationId; - use services::responses::ports::ResponseItemRepositoryTrait; - use services::workspace::WorkspaceId; - use uuid::Uuid; - - struct WorkspaceFixture { - workspace_id: WorkspaceId, - conversation_id: ConversationId, - /// Item IDs (e.g. "msg_") in chronological order. - item_ids: Vec, - } - - fn parse_conv_uuid(conversation_id: &str) -> Uuid { - let raw = conversation_id - .strip_prefix("conv_") - .unwrap_or(conversation_id); - Uuid::parse_str(raw).expect("conversation id should contain a UUID") - } - - /// Creates an org + API key, a conversation, and `item_count` backfilled - /// items via the public API; returns the raw IDs for repository-level use. - async fn seed_workspace(server: &axum_test::TestServer, item_count: usize) -> WorkspaceFixture { - let org = create_org(server).await; - let workspaces = list_workspaces(server, org.id.clone()).await; - let workspace = workspaces.first().expect("org should have a workspace"); - let workspace_id = - WorkspaceId(Uuid::parse_str(&workspace.id).expect("workspace id should be a UUID")); - let api_key = get_api_key_for_org(server, org.id.clone()).await; - - let create_response = server - .post("/v1/conversations") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({})) - .await; - assert_eq!(create_response.status_code(), 201); - let conversation = create_response.json::(); - let conversation_id = ConversationId(parse_conv_uuid(&conversation.id)); - - let mut item_ids = Vec::new(); - for i in 0..item_count { - let response = server - .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "items": [{ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": format!("item {i}")}] - }] - })) - .await; - assert_eq!(response.status_code(), 200); - let created = response.json::(); - item_ids.push(created.first_id.clone()); - } - - WorkspaceFixture { - workspace_id, - conversation_id, - item_ids, - } - } - - fn is_cursor_rejection(error: &anyhow::Error) -> bool { - error - .chain() - .filter_map(|cause| cause.downcast_ref::()) - .any(|e| matches!(e, services::common::RepositoryError::NotFound(_))) - } - - #[tokio::test] - async fn test_list_by_conversation_constrained_by_workspace() { - let (server, database) = setup_test_server_with_database().await; - let repo = PgResponseItemsRepository::new(database.pool().clone()); - - let ws_a = seed_workspace(&server, 3).await; - let ws_b = seed_workspace(&server, 1).await; - - // Owner sees its own items. - let own_items = repo - .list_by_conversation(ws_a.conversation_id, ws_a.workspace_id.clone(), None, 10) - .await - .expect("owner listing should succeed"); - assert_eq!(own_items.len(), 3, "owner should see all 3 items"); - - // The same conversation queried with a foreign workspace returns - // nothing, even though the conversation ID is known. - let foreign_items = repo - .list_by_conversation(ws_a.conversation_id, ws_b.workspace_id.clone(), None, 10) - .await - .expect("foreign listing should not error"); - assert!( - foreign_items.is_empty(), - "workspace constraint must exclude foreign conversation items" - ); - } - - #[tokio::test] - async fn test_list_by_conversation_rejects_foreign_and_unknown_cursors() { - let (server, database) = setup_test_server_with_database().await; - let repo = PgResponseItemsRepository::new(database.pool().clone()); - - let ws_a = seed_workspace(&server, 3).await; - let ws_b = seed_workspace(&server, 1).await; - - // A cursor from the same conversation works. - let page = repo - .list_by_conversation( - ws_a.conversation_id, - ws_a.workspace_id.clone(), - Some(ws_a.item_ids[0].clone()), - 10, - ) - .await - .expect("own cursor should be accepted"); - assert_eq!(page.len(), 2, "cursor should skip the first item"); - - // A cursor that belongs to another workspace's conversation is rejected. - let foreign_cursor = repo - .list_by_conversation( - ws_a.conversation_id, - ws_a.workspace_id.clone(), - Some(ws_b.item_ids[0].clone()), - 10, - ) - .await; - let err = foreign_cursor.expect_err("foreign cursor must be rejected"); - assert!( - is_cursor_rejection(&err), - "foreign cursor should surface as a cursor rejection, got: {err:?}" - ); - - // An unknown cursor is rejected the same way (non-enumerating). - let unknown_cursor = repo - .list_by_conversation( - ws_a.conversation_id, - ws_a.workspace_id.clone(), - Some(format!("msg_{}", Uuid::new_v4().simple())), - 10, - ) - .await; - let err = unknown_cursor.expect_err("unknown cursor must be rejected"); - assert!(is_cursor_rejection(&err)); - - // A cursor from the caller's own OTHER workspace conversation is also - // rejected: it must belong to this exact conversation. - let ws_b_own_listing = repo - .list_by_conversation( - ws_b.conversation_id, - ws_b.workspace_id.clone(), - Some(ws_a.item_ids[0].clone()), - 10, - ) - .await; - assert!( - ws_b_own_listing.is_err(), - "cross-conversation cursor rejected" - ); - } - - #[tokio::test] - async fn test_get_by_id_constrained_by_workspace() { - let (server, database) = setup_test_server_with_database().await; - let repo = PgResponseItemsRepository::new(database.pool().clone()); - - let ws_a = seed_workspace(&server, 1).await; - let ws_b = seed_workspace(&server, 1).await; - - let raw_item_id = ws_a.item_ids[0] - .rsplit('_') - .next() - .expect("item id should have a UUID suffix"); - let item_uuid = Uuid::parse_str(raw_item_id).expect("item id should parse"); - let item_id = services::responses::models::ResponseItemId(item_uuid); - - // Owner can fetch the item. - let own = repo - .get_by_id(item_id.clone(), ws_a.workspace_id.clone()) - .await - .expect("owner get_by_id should succeed"); - assert!(own.is_some(), "owner should see its own item"); - - // A foreign workspace gets None for the same item ID, identical to a - // nonexistent item (non-enumerating). - let foreign = repo - .get_by_id(item_id, ws_b.workspace_id.clone()) - .await - .expect("foreign get_by_id should not error"); - assert!(foreign.is_none(), "foreign workspace must not see the item"); - } -} From 4a14ff1d598d415180eb9e95bd6ca353c1597973 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:10:41 +0800 Subject: [PATCH 07/31] fix(responses): exclude input items from output --- crates/services/src/responses/service.rs | 151 +++++++++++++++++++---- 1 file changed, 128 insertions(+), 23 deletions(-) diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index 75512bb16..fefdaea4c 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -1,5 +1,4 @@ -use std::pin::Pin; -use std::sync::Arc; +use std::{collections::HashSet, pin::Pin, sync::Arc}; use async_trait::async_trait; use futures::Stream; @@ -1024,8 +1023,10 @@ impl ResponseServiceImpl { Uuid::parse_str(uuid_str).ok().map(ConversationId) }); - // Store user input messages as response_items - if let Some(input) = &context.request.input { + // Store request input as response items and keep the IDs created for + // this request. Input may contain historical assistant messages, so + // role alone cannot distinguish it from generated output later. + let input_item_ids = if let Some(input) = &context.request.input { Self::store_input_as_response_items( &context.response_items_repository, response_id.clone(), @@ -1035,8 +1036,10 @@ impl ResponseServiceImpl { &context.request.model, context.request.metadata.as_ref(), ) - .await?; - } + .await? + } else { + HashSet::new() + }; // Initialize context and emitter let mut ctx = crate::responses::service_helpers::ResponseStreamContext::new( @@ -1237,17 +1240,7 @@ impl ResponseServiceImpl { errors::ResponseError::InternalError(format!("Failed to load response items: {e}")) })?; - // Filter to get only assistant-generated output items. - // Exclude user input messages and client-provided FunctionCallOutput items, - // which are stored for history but should not appear in the response output. - let mut output_items: Vec<_> = response_items - .into_iter() - .filter(|item| match item { - models::ResponseOutputItem::Message { role, .. } => role == "assistant", - models::ResponseOutputItem::FunctionCallOutput { .. } => false, - _ => true, - }) - .collect(); + let mut output_items = Self::select_output_items(response_items, &input_item_ids); // Prepend MCP list tools items (emitted but not stored in DB) if let Some(ref mcp_executor) = context.mcp_executor { @@ -2047,7 +2040,9 @@ impl ResponseServiceImpl { Ok(messages) } - /// Store user input messages as response_items + /// Store request input as response items and return the IDs that originated + /// from the client. The IDs are kept only for this request and ensure that + /// historical assistant messages are never returned as new output. async fn store_input_as_response_items( response_items_repository: &Arc, response_id: models::ResponseId, @@ -2056,7 +2051,9 @@ impl ResponseServiceImpl { input: &models::ResponseInput, model: &str, request_metadata: Option<&serde_json::Value>, - ) -> Result<(), errors::ResponseError> { + ) -> Result, errors::ResponseError> { + let mut input_item_ids = HashSet::new(); + match input { models::ResponseInput::Text(text) => { // Create a message item for simple text input @@ -2078,7 +2075,7 @@ impl ResponseServiceImpl { metadata: request_metadata.cloned(), }; - response_items_repository + let stored_item = response_items_repository .create( response_id.clone(), api_key_id, @@ -2091,6 +2088,7 @@ impl ResponseServiceImpl { "Failed to store user input: {e}" )) })?; + input_item_ids.insert(stored_item.id().to_string()); } models::ResponseInput::Items(items) => { // Store each input item as a response_item @@ -2124,7 +2122,7 @@ impl ResponseServiceImpl { call_id: call_id.clone(), output: output.clone(), }; - response_items_repository + let stored_item = response_items_repository .create(response_id.clone(), api_key_id, conversation_id, fco_item) .await .map_err(|e| { @@ -2132,6 +2130,7 @@ impl ResponseServiceImpl { "Failed to store function call output: {e}" )) })?; + input_item_ids.insert(stored_item.id().to_string()); continue; } }; @@ -2193,7 +2192,7 @@ impl ResponseServiceImpl { metadata, }; - response_items_repository + let stored_item = response_items_repository .create( response_id.clone(), api_key_id, @@ -2206,6 +2205,7 @@ impl ResponseServiceImpl { "Failed to store user input item: {e}" )) })?; + input_item_ids.insert(stored_item.id().to_string()); } } } @@ -2214,7 +2214,25 @@ impl ResponseServiceImpl { "Stored user input messages as response_items for response {}", response_id.0 ); - Ok(()) + Ok(input_item_ids) + } + + /// Select items created while producing this response, never request input. + fn select_output_items( + response_items: Vec, + input_item_ids: &HashSet, + ) -> Vec { + response_items + .into_iter() + .filter(|item| { + !input_item_ids.contains(item.id()) + && match item { + models::ResponseOutputItem::Message { role, .. } => role == "assistant", + models::ResponseOutputItem::FunctionCallOutput { .. } => false, + _ => true, + } + }) + .collect() } /// Load conversation context based on conversation_id or previous_response_id. @@ -3608,6 +3626,93 @@ mod tests { use super::*; use crate::responses::tools::WEB_SEARCH_TOOL_NAME; + #[tokio::test] + async fn historical_assistant_input_is_not_returned_as_response_output() { + let (response_repository, response_items_repository) = transient::repositories(); + let workspace_id = crate::workspace::WorkspaceId(Uuid::new_v4()); + let api_key_id = Uuid::new_v4(); + let request = models::CreateResponseRequest { + model: "test-model".to_string(), + input: None, + instructions: None, + conversation: None, + previous_response_id: None, + max_output_tokens: None, + max_tool_calls: None, + temperature: None, + top_p: None, + stream: None, + store: Some(false), + background: Some(false), + tools: None, + tool_choice: None, + parallel_tool_calls: None, + reasoning: None, + include: None, + metadata: None, + safety_identifier: None, + prompt_cache_key: None, + }; + let response = response_repository + .create(workspace_id, api_key_id, request) + .await + .unwrap(); + let response_id = ResponseServiceImpl::extract_response_uuid(&response).unwrap(); + + let input = models::ResponseInput::Items(vec![models::ResponseInputItem::Message { + role: "assistant".to_string(), + content: models::ResponseContent::Text("historical answer".to_string()), + metadata: None, + }]); + let input_item_ids = ResponseServiceImpl::store_input_as_response_items( + &response_items_repository, + response_id.clone(), + api_key_id, + None, + &input, + "test-model", + None, + ) + .await + .unwrap(); + + let generated_item_id = format!("msg_{}", Uuid::new_v4().simple()); + response_items_repository + .create( + response_id.clone(), + api_key_id, + None, + models::ResponseOutputItem::Message { + id: generated_item_id.clone(), + response_id: String::new(), + previous_response_id: None, + next_response_ids: vec![], + created_at: 0, + status: models::ResponseItemStatus::Completed, + role: "assistant".to_string(), + content: vec![models::ResponseContentItem::OutputText { + text: "new answer".to_string(), + annotations: vec![], + logprobs: vec![], + }], + model: "test-model".to_string(), + metadata: None, + }, + ) + .await + .unwrap(); + + let response_items = response_items_repository + .list_by_response(response_id) + .await + .unwrap(); + let output_items = + ResponseServiceImpl::select_output_items(response_items, &input_item_ids); + + assert_eq!(output_items.len(), 1); + assert_eq!(output_items[0].id(), generated_item_id); + } + #[test] fn test_process_reasoning_tags_simple_think() { let mut reasoning_buffer = String::new(); From cf2da66294a25242c63fa8b111cf0d1eb97765c0 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:10:52 +0800 Subject: [PATCH 08/31] docs: clarify stateless Responses attestation retention --- CLAUDE.md | 48 ++++++++++++++++++++------------------- crates/api/src/openapi.rs | 2 +- docs/local-development.md | 18 +++++++++++++-- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a2cea7d95..878ede010 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ Production runs at **info level and above**. We ABSOLUTELY CANNOT and SHOULD NOT - **AI responses** - Model outputs, completions, or generated text - **Metadata that reveals customer information** - Custom fields, tags, labels that could expose user activity - **File contents** - Uploaded file data or processed file content +- **Content-derived metadata** - Request/response digests, signature payloads, or other stable derivations of customer content - **Any PII** - Names, emails (except for auth flow), addresses, phone numbers in user content #### ✓ OK TO LOG (Permitted for Debugging) @@ -103,14 +104,14 @@ cargo test --lib --bins # Run ALL e2e tests (requires PostgreSQL running) cargo test --test e2e_test -# Run a single e2e test file -cargo test --test e2e_conversations +# Run the stateless Responses e2e module +cargo test -p api --test e2e_all responses_stateless # Run vLLM integration tests (requires vLLM server) cargo test --test integration_tests -# Run a specific test by name -cargo test test_create_conversation +# Run a specific Responses test by name +cargo test -p api --test e2e_all responses_stateless ``` ### Database Setup for Tests @@ -172,8 +173,8 @@ Organization (Tenant Root) - Storage: Hashed session token in database, returned as HTTP-only cookie **2. API Key-Based (AI Inference Operations)** -- Used for: Chat completions, conversations, responses, attestation -- Endpoints: `/v1/chat/completions`, `/v1/responses/*`, `/v1/conversations/*` +- Used for: Chat completions, Responses, and attestation +- Endpoints: `/v1/chat/completions`, `POST /v1/responses`, `/v1/signature/{chat_id}`, `/v1/attestation/*` - Format: `Authorization: Bearer sk-live-xxx` or `Authorization: Bearer sk-test-xxx` - Storage: SHA-256 hashed, workspace-scoped - Tracking: Last used timestamp, optional expiration @@ -191,19 +192,17 @@ POST /v1/completions - Supports streaming (SSE) and non-streaming - Standard OpenAI format with `[DONE]` terminator -**B. Response API (Platform-specific)** +**B. Responses API (single-turn, stateless)** ``` POST /v1/responses ``` -- Links to conversation history for context -- Rich metadata and event types +- `store: false` is the only supported mode; an omitted `store` is treated as `false` and `store: true` is rejected +- Clients send all needed prior context in each request. Conversations, `previous_response_id`, background responses, and response-history endpoints are retired or unsupported. +- Raw request/response content, response items, and conversation history are not persisted. +- **Narrow attestation exception**: to support later `GET /v1/signature/resp_*` lookup, the service retains the response ID, SHA-256 request/response digests, signatures, signing metadata, and timestamps. These are content-derived sensitive metadata, not raw content; do not log them, and do not treat deterministic hashes as anonymous when the underlying content may be guessable. - Event types: `response.created`, `response.output_text.delta`, `response.completed`, `response.failed` -- **Tool use**: Supports external function calls, code_interpreter, computer tools (client-executed), - plus server-executed web_search, file_search, and MCP tools -- **Function call flow**: LLM requests a function, response pauses with status `incomplete`, - client executes and resumes via `previous_response_id` + `FunctionCallOutput` input. - Resumption verifies workspace ownership on `previous_response_id` and validates - each `call_id` maps to exactly one stored FunctionCall +- `/v1/conversations/*` and `/v1/files/*` return authenticated `410 Gone` responses. +- Stateful tools and continuations are unsupported: file input/file search, function tools and function-call continuation, code interpreter, computer, and MCP approval continuation. Only request-scoped MCP calls with `require_approval: "never"` are supported. **Streaming Flow**: ``` @@ -217,7 +216,7 @@ Client → CompletionService → Provider Pool (round-robin) - Discovery Server polled every 5 minutes (configurable) - `GET /models` returns available models and their vLLM endpoints - Provider Pool updated dynamically (no hardcoded models) -- Load balancing: round-robin for new requests, sticky routing for conversations +- Load balancing: round-robin for new requests ### Database Layer (Patroni High-Availability) - PostgreSQL 16 with deadpool connection pooling @@ -226,6 +225,10 @@ Client → CompletionService → Provider Pool (round-robin) - **Migrations**: SQL-based using Refinery, run on startup - Located at: `crates/database/src/migrations/sql/` +Historical Conversation, Response, ResponseItem, and File schemas/repositories remain for now, +but the retired public APIs must not be wired back to them. Data deletion and schema removal are +separate work. + ### External Billing / Credits Service Cloud API itself has no direct Stripe integration. The billing/credits service is the @@ -256,14 +259,14 @@ Located in `crates/services/src/`: - `workspace` - Workspace CRUD, settings - `user` - User profiles, session management - `completions` - AI completion orchestration -- `conversations` - Conversation lifecycle -- `responses` - Response streaming with token tracking, tool orchestration (function calls, web search, file search, MCP) +- `conversations` - Legacy module; its public API surface is retired +- `responses` - Request-scoped, stateless response orchestration and supported one-turn tools - `attestation` - TEE attestation reports, chat signatures - `models` - Model catalog and pricing - `usage` - Token tracking, limit enforcement, billing - `inference_provider_pool` - Model discovery, load balancing - `mcp` - Model Context Protocol client management -- `files` - File storage (AWS S3) +- `files` - Legacy module; its public API surface is retired - `metrics` - OpenTelemetry metrics - `admin` - Admin operations, analytics - `common` - Shared utilities @@ -276,13 +279,13 @@ Located in `crates/api/src/routes/`: - `workspaces.rs` - Workspace & API key management - `users.rs` - User profile, invitations, sessions - `completions.rs` - Chat & text completions -- `conversations.rs` - Conversation management -- `responses.rs` - AI response streaming +- `conversations.rs` - Retired API surface (authenticated `410 Gone`) +- `responses.rs` - Stateless AI response streaming - `models.rs` - Model catalog - `usage.rs` - Usage tracking, billing - `attestation.rs` - TEE verification, signatures - `admin.rs` - Admin endpoints -- `files.rs` - File upload/download +- `files.rs` - Retired API surface (authenticated `410 Gone`) - `health.rs` - Health checks - `api.rs` - API versioning @@ -355,7 +358,6 @@ Comprehensive C4 diagrams and flows: `docs/architecture/c4-diagrams.md` - No hardcoded model configuration - Automatic scaling as inference servers added/removed - Graceful handling of provider failures -- Conversation consistency via sticky routing ## API Documentation diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 2b473de2a..0a6481d67 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -25,7 +25,7 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Responses", description = "Single-turn, stateless response inference. Response and item content are not persisted; billing, rate limiting, and operations retain only the minimum non-content metadata."), + (name = "Responses", description = "Single-turn, stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. To support `GET /v1/signature/resp_*`, the service retains a narrow attestation record: response ID, SHA-256 request/response digests, signatures, signing metadata, and timestamps. These content-derived values are treated as sensitive metadata."), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), diff --git a/docs/local-development.md b/docs/local-development.md index e4e4fb1d0..d1c5f6725 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -272,14 +272,28 @@ Provider refresh runs every 300s by default | `POST /v1/workspaces/{id}/api-keys` | session | Returns plaintext `key` — store it, it isn't shown again | | `GET /v1/models` | public | OpenAI-compatible model catalog with pricing metadata | | `POST /v1/chat/completions` | API key | OpenAI-compatible. Add `"stream": true` for SSE | -| `POST /v1/responses` | API key | Single-turn no-store response inference; response history is unavailable | +| `POST /v1/responses` | API key | Single-turn `store: false` inference; response history is unavailable | | `GET /v1/attestation/report` | API key | TEE attestation (503 outside a CVM unless `DEV=true` in debug builds) | | `GET /v1/attestation/ita-token` | public | Intel Trust Authority JWT wrapper (requires ITA env vars) | -| `GET /v1/signature/{chat_id}` | API key | Per-completion signature lookup | +| `GET /v1/signature/{chat_id}` | API key | Per-completion and `resp_*` signature lookup | The Scalar UI at `http://localhost:3000/docs` lets you fire each of these interactively and inspect request/response schemas. +### Stateless Responses and attestation retention + +`POST /v1/responses` is single-turn and accepts only `store: false` (an omitted +value is treated as `false`). Cloud API does not retain raw request or response +content, response items, or response history; clients must supply any needed +prior context with each request. + +The narrow exception is the attestation record needed for later +`GET /v1/signature/resp_*` lookup: response ID, SHA-256 request/response +digests, signatures, signing metadata, and timestamps. These are +content-derived sensitive metadata rather than raw content. In particular, +deterministic hashes must not be treated as anonymous if the underlying content +could be guessed and compared offline. + ## 7. Troubleshooting **`make dev` fails with "Seed directory not found"** From 2d20c5586eff6607b078ed7dd7b8989bec6687c7 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:18:55 +0800 Subject: [PATCH 09/31] fix(responses): preserve no-store attestations --- crates/api/src/lib.rs | 3 +- crates/api/src/routes/responses.rs | 404 ++++++++++++++++++++++++++++- 2 files changed, 397 insertions(+), 10 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 307dd1003..df14d4786 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1650,13 +1650,14 @@ pub fn build_completion_routes( /// Build response routes with auth pub fn build_response_routes( response_service: Arc, - _attestation_service: Arc, + attestation_service: Arc, auth_state_middleware: &AuthState, usage_state: middleware::UsageState, rate_limit_state: middleware::RateLimitState, ) -> Router { let route_state = responses::ResponseRouteState { response_service: response_service.clone(), + attestation_service, }; let inference_routes = Router::new() diff --git a/crates/api/src/routes/responses.rs b/crates/api/src/routes/responses.rs index b9336e058..a14b876e4 100644 --- a/crates/api/src/routes/responses.rs +++ b/crates/api/src/routes/responses.rs @@ -11,14 +11,22 @@ use axum::{ }; use bytes::Bytes; use futures::stream::StreamExt; +use services::attestation::ports::AttestationServiceTrait; use services::responses::errors::ResponseError as ServiceResponseError; use services::responses::models::*; use services::responses::ports::ResponseServiceTrait; use services::responses::service::ResponseServiceImpl; +use sha2::{Digest, Sha256}; use std::convert::Infallible; -use std::sync::Arc; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use tracing::debug; +/// Bound best-effort attestation persistence so a database problem never +/// prevents a completed inference response from being delivered. +const RESPONSE_ATTESTATION_STORE_TIMEOUT: Duration = Duration::from_secs(5); + // Helper functions for error mapping fn map_response_error_to_status(error: &ServiceResponseError) -> StatusCode { match error { @@ -154,6 +162,144 @@ impl From for ErrorResponse { #[derive(Clone)] pub struct ResponseRouteState { pub response_service: Arc, + /// Stateless Responses retain only attestation material: the response ID, + /// request/response digests, and their signatures. No response or item + /// records are retained by this route. + pub attestation_service: Arc, +} + +/// Request-local state used to sign the exact SSE bytes returned to a client. +/// +/// The state deliberately retains a running digest rather than accumulating +/// stream content, so no response payload is kept after an event is emitted. +struct StreamingResponseAttestation { + response_id: Option, + response_hasher: Sha256, + completed: bool, +} + +impl Default for StreamingResponseAttestation { + fn default() -> Self { + Self { + response_id: None, + response_hasher: Sha256::new(), + completed: false, + } + } +} + +impl StreamingResponseAttestation { + /// Record one client-visible SSE frame and return attestation material when + /// the response has completed. The returned digest includes the completed + /// frame itself. + fn record_event( + &mut self, + event: &ResponseStreamEvent, + sse_bytes: &[u8], + ) -> Option<(String, String)> { + if self.response_id.is_none() { + self.response_id = event.response.as_ref().map(|response| response.id.clone()); + } + + self.response_hasher.update(sse_bytes); + + if self.completed || event.event_type != "response.completed" { + return None; + } + self.completed = true; + + self.response_id.clone().map(|response_id| { + let response_hash = hex::encode(self.response_hasher.clone().finalize()); + (response_id, response_hash) + }) + } +} + +/// Persist the minimal metadata needed to retrieve an attestation later. +/// +/// Attestation failures must not change the inference result. In particular, +/// do not include provider/database error strings here: they may contain +/// request-derived data or infrastructure details. +async fn persist_response_attestation( + attestation_service: &dyn AttestationServiceTrait, + response_id: &str, + request_hash: String, + response_hash: String, +) { + match tokio::time::timeout( + RESPONSE_ATTESTATION_STORE_TIMEOUT, + attestation_service.store_response_signature(response_id, request_hash, response_hash), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(_)) => { + tracing::warn!(%response_id, "Response attestation persistence failed"); + } + Err(_) => { + tracing::warn!(%response_id, "Response attestation persistence timed out"); + } + } +} + +/// Store an attestation for a non-streaming response before sending it. +async fn persist_non_streaming_response_attestation( + attestation_service: &dyn AttestationServiceTrait, + response: &ResponseObject, + request_hash: String, +) { + // This serialization is request-local and is immediately reduced to a + // digest. The response itself is not written to the attestation store. + let response_json = serde_json::to_vec(response).expect("response serialization failed"); + let response_hash = hex::encode(Sha256::digest(&response_json)); + persist_response_attestation( + attestation_service, + &response.id, + request_hash, + response_hash, + ) + .await; +} + +/// Convert response events into signed SSE frames. The response-completed +/// frame is held until its attestation write has finished, so a client that has +/// received that frame can immediately look up its `resp_*` signature. +fn signed_response_sse_stream( + stream: Pin + Send>>, + attestation_service: Arc, + request_hash: String, +) -> Pin> + Send>> { + let signature_state = Arc::new(Mutex::new(StreamingResponseAttestation::default())); + + Box::pin(stream.then(move |event| { + let signature_state = signature_state.clone(); + let attestation_service = attestation_service.clone(); + let request_hash = request_hash.clone(); + + async move { + let json = serde_json::to_string(&event).expect("event serialization failed"); + let sse_bytes = format!("event: {}\ndata: {}\n\n", event.event_type, json); + + let signature = { + let mut state = signature_state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.record_event(&event, sse_bytes.as_bytes()) + }; + + if let Some((response_id, response_hash)) = signature { + persist_response_attestation( + attestation_service.as_ref(), + &response_id, + request_hash, + response_hash, + ) + .await; + } + + Ok::(Bytes::from(sse_bytes)) + } + })) } /// Return an explicit migration response for the retired response-history API. @@ -304,14 +450,11 @@ pub async fn create_response( "Successfully created streaming response" ); - // Format events as SSE without retaining the stream payload or - // writing a response attestation. A no-store response has no - // durable response record to associate with such data. - let byte_stream = stream.map(|event| { - let json = serde_json::to_string(&event).expect("event serialization failed"); - let sse_bytes = format!("event: {}\ndata: {}\n\n", event.event_type, json); - Ok::(Bytes::from(sse_bytes)) - }); + let byte_stream = signed_response_sse_stream( + stream, + state.attestation_service.clone(), + body_hash.hash.clone(), + ); // Return as raw byte stream with SSE headers Response::builder() @@ -553,6 +696,17 @@ pub async fn create_response( response.id, api_key.api_key.created_by_user_id.0 ); + // Store the signature before returning so an immediate + // GET /v1/signature/resp_* lookup is deterministic. This only + // writes the response ID, request/response digests, and + // signatures; the response body remains no-store. + persist_non_streaming_response_attestation( + state.attestation_service.as_ref(), + &response, + body_hash.hash.clone(), + ) + .await; + with_no_store_cache_header((StatusCode::OK, ResponseJson(response)).into_response()) } Err(error) => { @@ -574,6 +728,152 @@ pub async fn create_response( #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use futures::StreamExt; + use services::attestation::{ + ita::{ItaTokenQuery, ItaTokenResponse}, + AttestationError, SignatureLookupResult, + }; + + #[derive(Clone, Default)] + struct RecordingAttestationService { + stored_response_signatures: Arc>>, + } + + impl RecordingAttestationService { + fn stored_response_signatures(&self) -> Vec<(String, String, String)> { + self.stored_response_signatures + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + } + + #[async_trait] + impl AttestationServiceTrait for RecordingAttestationService { + async fn get_chat_signature( + &self, + _chat_id: &str, + _signing_algo: Option, + ) -> Result { + Err(AttestationError::InternalError("unused".to_string())) + } + + async fn store_chat_signature_from_provider( + &self, + _chat_id: &str, + ) -> Result<(), AttestationError> { + Ok(()) + } + + async fn store_chat_signature( + &self, + _chat_id: &str, + _request_hash: String, + _response_hash: String, + ) -> Result<(), AttestationError> { + Ok(()) + } + + async fn store_response_signature( + &self, + response_id: &str, + request_hash: String, + response_hash: String, + ) -> Result<(), AttestationError> { + self.stored_response_signatures + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((response_id.to_string(), request_hash, response_hash)); + Ok(()) + } + + async fn get_attestation_report( + &self, + _model: Option, + _signing_algo: Option, + _nonce: Option, + _signing_address: Option, + _include_tls_fingerprint: bool, + _provider_filter: Option, + ) -> Result { + Err(AttestationError::InternalError("unused".to_string())) + } + + async fn get_ita_attestation_token( + &self, + _query: ItaTokenQuery, + ) -> Result { + Err(AttestationError::InternalError("unused".to_string())) + } + + async fn verify_vpc_signature( + &self, + _timestamp: i64, + _signature: String, + ) -> Result { + Ok(false) + } + } + + fn sample_response(response_id: &str) -> ResponseObject { + ResponseObject { + id: response_id.to_string(), + object: "response".to_string(), + created_at: 0, + status: ResponseStatus::Completed, + background: false, + conversation: None, + error: None, + incomplete_details: None, + instructions: None, + max_output_tokens: None, + max_tool_calls: None, + model: "test-model".to_string(), + output: vec![], + parallel_tool_calls: false, + previous_response_id: None, + next_response_ids: vec![], + prompt_cache_key: None, + prompt_cache_retention: None, + reasoning: None, + safety_identifier: None, + service_tier: "default".to_string(), + store: false, + temperature: 1.0, + tool_choice: ResponseToolChoiceOutput::Auto("auto".to_string()), + tools: vec![], + top_logprobs: 0, + top_p: 1.0, + truncation: "disabled".to_string(), + usage: Usage::new(0, 0), + user: None, + metadata: None, + } + } + + fn stream_event(event_type: &str, response: Option) -> ResponseStreamEvent { + ResponseStreamEvent { + event_type: event_type.to_string(), + sequence_number: None, + response, + output_index: None, + content_index: None, + item: None, + item_id: None, + part: None, + delta: None, + text: None, + error: None, + status_code: None, + logprobs: None, + obfuscation: None, + annotation_index: None, + annotation: None, + conversation_title: None, + usage: None, + } + } #[test] fn response_results_are_marked_no_store() { @@ -599,4 +899,90 @@ mod tests { Some("no-store") ); } + + #[tokio::test] + async fn streaming_response_stores_signature_before_completed_frame() { + let attestation = Arc::new(RecordingAttestationService::default()); + let response_id = "resp_11111111-1111-4111-8111-111111111111"; + let events = vec![ + stream_event("response.created", Some(sample_response(response_id))), + stream_event("response.completed", Some(sample_response(response_id))), + ]; + let mut stream = signed_response_sse_stream( + Box::pin(futures::stream::iter(events)), + attestation.clone(), + "request-digest".to_string(), + ); + + let created_frame = stream + .next() + .await + .expect("created frame") + .expect("infallible frame"); + assert!(attestation.stored_response_signatures().is_empty()); + + let completed_frame = stream + .next() + .await + .expect("completed frame") + .expect("infallible frame"); + + let signatures = attestation.stored_response_signatures(); + assert_eq!(signatures.len(), 1); + let (stored_response_id, request_hash, response_hash) = &signatures[0]; + assert_eq!(stored_response_id, response_id); + assert_eq!(request_hash, "request-digest"); + + let mut expected_hasher = Sha256::new(); + expected_hasher.update(&created_frame); + expected_hasher.update(&completed_frame); + assert_eq!(response_hash, &hex::encode(expected_hasher.finalize())); + } + + #[tokio::test] + async fn client_disconnect_before_completion_does_not_store_partial_signature() { + let attestation = Arc::new(RecordingAttestationService::default()); + let response_id = "resp_22222222-2222-4222-8222-222222222222"; + let mut stream = signed_response_sse_stream( + Box::pin(futures::stream::iter(vec![ + stream_event("response.created", Some(sample_response(response_id))), + stream_event("response.output_text.delta", None), + stream_event("response.completed", Some(sample_response(response_id))), + ])), + attestation.clone(), + "request-digest".to_string(), + ); + + let _created_frame = stream + .next() + .await + .expect("created frame") + .expect("infallible frame"); + drop(stream); + + assert!(attestation.stored_response_signatures().is_empty()); + } + + #[tokio::test] + async fn non_streaming_response_stores_response_json_digest() { + let attestation = RecordingAttestationService::default(); + let response = sample_response("resp_33333333-3333-4333-8333-333333333333"); + + persist_non_streaming_response_attestation( + &attestation, + &response, + "request-digest".to_string(), + ) + .await; + + let signatures = attestation.stored_response_signatures(); + assert_eq!(signatures.len(), 1); + let (stored_response_id, request_hash, response_hash) = &signatures[0]; + assert_eq!(stored_response_id, &response.id); + assert_eq!(request_hash, "request-digest"); + let expected_hash = hex::encode(Sha256::digest( + serde_json::to_vec(&response).expect("response serialization"), + )); + assert_eq!(response_hash, &expected_hash); + } } From eed57aa3cea55f34b59cb3ef4a3941e7b52840d7 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:26:46 +0800 Subject: [PATCH 10/31] fix(api): treat content-derived hashes as sensitive --- crates/api/src/middleware/body_hash.rs | 5 ++--- crates/api/src/routes/attestation/signature.rs | 9 +++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/api/src/middleware/body_hash.rs b/crates/api/src/middleware/body_hash.rs index 6fe039744..ae7bc52ee 100644 --- a/crates/api/src/middleware/body_hash.rs +++ b/crates/api/src/middleware/body_hash.rs @@ -49,9 +49,8 @@ pub async fn body_hash_middleware(request: Request, next: Next) -> Result Date: Wed, 19 Aug 2026 18:07:16 +0800 Subject: [PATCH 11/31] fix(responses): align stateless retention boundary --- CLAUDE.md | 5 +- crates/api/src/middleware/body_hash.rs | 5 +- crates/api/src/openapi.rs | 2 +- crates/api/src/routes/responses.rs | 140 +++++++----------- crates/api/tests/e2e_all/client_disconnect.rs | 7 +- .../api/tests/e2e_all/responses_stateless.rs | 90 +++++++++++ docs/local-development.md | 17 ++- 7 files changed, 163 insertions(+), 103 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 878ede010..f454a5a4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,6 @@ Production runs at **info level and above**. We ABSOLUTELY CANNOT and SHOULD NOT - **AI responses** - Model outputs, completions, or generated text - **Metadata that reveals customer information** - Custom fields, tags, labels that could expose user activity - **File contents** - Uploaded file data or processed file content -- **Content-derived metadata** - Request/response digests, signature payloads, or other stable derivations of customer content - **Any PII** - Names, emails (except for auth flow), addresses, phone numbers in user content #### ✓ OK TO LOG (Permitted for Debugging) @@ -199,10 +198,10 @@ POST /v1/responses - `store: false` is the only supported mode; an omitted `store` is treated as `false` and `store: true` is rejected - Clients send all needed prior context in each request. Conversations, `previous_response_id`, background responses, and response-history endpoints are retired or unsupported. - Raw request/response content, response items, and conversation history are not persisted. -- **Narrow attestation exception**: to support later `GET /v1/signature/resp_*` lookup, the service retains the response ID, SHA-256 request/response digests, signatures, signing metadata, and timestamps. These are content-derived sensitive metadata, not raw content; do not log them, and do not treat deterministic hashes as anonymous when the underlying content may be guessable. +- **Existing completed-response attestation is preserved best-effort**: when its signature write succeeds, `GET /v1/signature/resp_*` can retrieve the response ID and signatures over SHA-256 request/response digests. The signature material contains no raw request or response content. A disconnected stream has no completed `resp_*` attestation record or legacy disconnect fallback. - Event types: `response.created`, `response.output_text.delta`, `response.completed`, `response.failed` - `/v1/conversations/*` and `/v1/files/*` return authenticated `410 Gone` responses. -- Stateful tools and continuations are unsupported: file input/file search, function tools and function-call continuation, code interpreter, computer, and MCP approval continuation. Only request-scoped MCP calls with `require_approval: "never"` are supported. +- The following stateful operations are rejected: file input/file search, function tools and function-call continuation, code interpreter, computer, and every MCP approval mode other than `require_approval: "never"`. Only request-scoped MCP calls with that exact mode are supported. **Streaming Flow**: ``` diff --git a/crates/api/src/middleware/body_hash.rs b/crates/api/src/middleware/body_hash.rs index ae7bc52ee..6fe039744 100644 --- a/crates/api/src/middleware/body_hash.rs +++ b/crates/api/src/middleware/body_hash.rs @@ -49,8 +49,9 @@ pub async fn body_hash_middleware(request: Request, next: Next) -> Result axum::response::Response { - response - .headers_mut() - .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - response -} - impl From for ErrorResponse { fn from(error: ServiceResponseError) -> Self { match error { @@ -162,9 +155,9 @@ impl From for ErrorResponse { #[derive(Clone)] pub struct ResponseRouteState { pub response_service: Arc, - /// Stateless Responses retain only attestation material: the response ID, - /// request/response digests, and their signatures. No response or item - /// records are retained by this route. + /// Existing completed-response gateway attestation remains best-effort. + /// Its stored material is a response ID plus signatures over + /// request/response digests, never raw response or item records. pub attestation_service: Arc, } @@ -262,8 +255,9 @@ async fn persist_non_streaming_response_attestation( } /// Convert response events into signed SSE frames. The response-completed -/// frame is held until its attestation write has finished, so a client that has -/// received that frame can immediately look up its `resp_*` signature. +/// frame is held until its best-effort attestation write has been attempted. +/// A successful write is available for subsequent `resp_*` lookup; a failed or +/// timed-out write does not change the inference result. fn signed_response_sse_stream( stream: Pin + Send>>, attestation_service: Arc, @@ -308,17 +302,14 @@ fn signed_response_sse_stream( /// separate from the create endpoint makes it clear that only a new, single /// stateless request is supported. pub async fn response_history_gone() -> axum::response::Response { - with_no_store_cache_header( - ( - StatusCode::GONE, - ResponseJson(ErrorResponse::new( - "Response history is unavailable because the Responses API is stateless." - .to_string(), - "gone".to_string(), - )), - ) - .into_response(), + ( + StatusCode::GONE, + ResponseJson(ErrorResponse::new( + "Response history is unavailable because the Responses API is stateless.".to_string(), + "gone".to_string(), + )), ) + .into_response() } /// Create response @@ -356,35 +347,31 @@ pub async fn create_response( // Validate the request if let Err(error) = request.validate() { - return with_no_store_cache_header( - ( - StatusCode::BAD_REQUEST, - ResponseJson(ErrorResponse::new( - error, - "invalid_request_error".to_string(), - )), - ) - .into_response(), - ); + return ( + StatusCode::BAD_REQUEST, + ResponseJson(ErrorResponse::new( + error, + "invalid_request_error".to_string(), + )), + ) + .into_response(); } if let Err(error) = request.validate_stateless() { - return with_no_store_cache_header( - ( - StatusCode::BAD_REQUEST, - ResponseJson(ErrorResponse::new( - error, - "invalid_request_error".to_string(), - )), - ) - .into_response(), - ); + return ( + StatusCode::BAD_REQUEST, + ResponseJson(ErrorResponse::new( + error, + "invalid_request_error".to_string(), + )), + ) + .into_response(); } // Extract and validate encryption headers if present let encryption_headers = match crate::routes::common::validate_encryption_headers(&headers) { Ok(headers) => headers, - Err(err) => return with_no_store_cache_header(err.into_response()), + Err(err) => return err.into_response(), }; let signing_algo = encryption_headers.signing_algo; @@ -395,17 +382,14 @@ pub async fn create_response( // Encryption requires streaming mode because encrypted chunks from vLLM are independently // encrypted and cannot be concatenated. Non-streaming mode would produce corrupted data. if signing_algo.is_some() && client_pub_key.is_some() && request.stream != Some(true) { - return with_no_store_cache_header( - ( - StatusCode::BAD_REQUEST, - ResponseJson(ErrorResponse::new( - "Non-streaming mode is not supported with encryption. Use stream=true." - .to_string(), - "encryption_requires_streaming".to_string(), - )), - ) - .into_response(), - ); + return ( + StatusCode::BAD_REQUEST, + ResponseJson(ErrorResponse::new( + "Non-streaming mode is not supported with encryption. Use stream=true.".to_string(), + "encryption_requires_streaming".to_string(), + )), + ) + .into_response(); } // Set defaults for internal fields @@ -473,9 +457,7 @@ pub async fn create_response( "Failed to create streaming response" ); let status_code = map_response_error_to_status(&error); - with_no_store_cache_header( - (status_code, ResponseJson::(error.into())).into_response(), - ) + (status_code, ResponseJson::(error.into())).into_response() } } } else { @@ -625,9 +607,7 @@ pub async fn create_response( if let Some(error) = failed_error { let status_code = status_code_from_response_event(failed_status_code); let error_response = error_response_from_response_event(error); - return with_no_store_cache_header( - (status_code, ResponseJson(error_response)).into_response(), - ); + return (status_code, ResponseJson(error_response)).into_response(); } } @@ -696,10 +676,10 @@ pub async fn create_response( response.id, api_key.api_key.created_by_user_id.0 ); - // Store the signature before returning so an immediate - // GET /v1/signature/resp_* lookup is deterministic. This only - // writes the response ID, request/response digests, and - // signatures; the response body remains no-store. + // Attempt the existing gateway signature write before returning. + // It stores only the response ID and signatures over + // request/response digests, never the response body. Failures + // and timeouts leave the no-store inference response unchanged. persist_non_streaming_response_attestation( state.attestation_service.as_ref(), &response, @@ -707,7 +687,7 @@ pub async fn create_response( ) .await; - with_no_store_cache_header((StatusCode::OK, ResponseJson(response)).into_response()) + (StatusCode::OK, ResponseJson(response)).into_response() } Err(error) => { tracing::error!( @@ -717,9 +697,7 @@ pub async fn create_response( "Failed to create non-streaming response" ); let status_code = map_response_error_to_status(&error); - with_no_store_cache_header( - (status_code, ResponseJson::(error.into())).into_response(), - ) + (status_code, ResponseJson::(error.into())).into_response() } } } @@ -875,29 +853,15 @@ mod tests { } } - #[test] - fn response_results_are_marked_no_store() { - let response = with_no_store_cache_header(StatusCode::OK.into_response()); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("no-store") - ); - } - #[tokio::test] async fn response_history_is_explicitly_gone() { let response = response_history_gone().await; assert_eq!(response.status(), StatusCode::GONE); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("no-store") - ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + let error: ErrorResponse = serde_json::from_slice(&body).expect("gone error body"); + assert_eq!(error.error.r#type, "gone"); } #[tokio::test] diff --git a/crates/api/tests/e2e_all/client_disconnect.rs b/crates/api/tests/e2e_all/client_disconnect.rs index 42ad0483f..a894545f6 100644 --- a/crates/api/tests/e2e_all/client_disconnect.rs +++ b/crates/api/tests/e2e_all/client_disconnect.rs @@ -1,8 +1,9 @@ //! Client-disconnect coverage for the supported Chat Completions API. //! -//! Responses no longer retain response IDs, items, or attestation signatures, -//! so their former persistence-focused disconnect tests are intentionally not -//! retained here. +//! Stateless Responses preserve gateway attestation only after a completed +//! response. A disconnected Responses stream creates no `resp_*` attestation +//! record or legacy disconnect fallback because it has no persisted response +//! ID, so this module keeps the supported Chat Completions fallback coverage. use crate::common::*; diff --git a/crates/api/tests/e2e_all/responses_stateless.rs b/crates/api/tests/e2e_all/responses_stateless.rs index 3eaeeed7d..d12097857 100644 --- a/crates/api/tests/e2e_all/responses_stateless.rs +++ b/crates/api/tests/e2e_all/responses_stateless.rs @@ -97,3 +97,93 @@ async fn stateless_responses_reject_persistent_fields() { assert_eq!(error.error.r#type, "invalid_request_error"); } } + +#[tokio::test] +async fn completed_stateless_response_exposes_gateway_signature_when_persisted() { + let server = setup_test_server().await; + let model = setup_qwen_model(&server).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let request = serde_json::json!({ + "model": model, + "input": "Respond with a short attested answer.", + "stream": false, + "store": false + }); + let expected_request_hash = compute_sha256( + &serde_json::to_string(&request).expect("serialize stateless Responses request"), + ); + + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&request) + .await; + + assert_eq!( + response.status_code(), + 200, + "stateless Responses request should succeed: {}", + response.text() + ); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + + let response_text = response.text(); + let expected_response_hash = compute_sha256(&response_text); + let response_json: serde_json::Value = + serde_json::from_str(&response_text).expect("Responses result must be JSON"); + let response_id = response_json + .get("id") + .and_then(|value| value.as_str()) + .expect("completed response must have an ID"); + assert!(response_id.starts_with("resp_")); + + let signature = server + .get(&format!("/v1/signature/{response_id}?signing_algo=ecdsa")) + .add_header("Authorization", format!("Bearer {api_key}")) + .await; + assert_eq!( + signature.status_code(), + 200, + "completed response signature should be available when persistence succeeds: {}", + signature.text() + ); + + let signature: serde_json::Value = signature.json(); + assert_eq!( + signature + .get("signature_kind") + .and_then(|value| value.as_str()), + Some("gateway") + ); + assert_eq!( + signature + .get("signing_algo") + .and_then(|value| value.as_str()), + Some("ecdsa") + ); + assert!( + signature + .get("signature") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()), + "gateway signature must be present" + ); + + let signed_hashes = signature + .get("text") + .and_then(|value| value.as_str()) + .expect("gateway signature must include its signed hashes"); + let (request_hash, response_hash) = signed_hashes + .split_once(':') + .expect("gateway signature text must be request_hash:response_hash"); + assert_eq!(request_hash, expected_request_hash); + assert_eq!(response_hash, expected_response_hash); +} diff --git a/docs/local-development.md b/docs/local-development.md index d1c5f6725..b6e94a29b 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -287,12 +287,17 @@ value is treated as `false`). Cloud API does not retain raw request or response content, response items, or response history; clients must supply any needed prior context with each request. -The narrow exception is the attestation record needed for later -`GET /v1/signature/resp_*` lookup: response ID, SHA-256 request/response -digests, signatures, signing metadata, and timestamps. These are -content-derived sensitive metadata rather than raw content. In particular, -deterministic hashes must not be treated as anonymous if the underlying content -could be guessed and compared offline. +Existing completed-response gateway attestation is preserved on a best-effort +basis. When its signature write succeeds, `GET /v1/signature/resp_*` can +retrieve the response ID and signatures over SHA-256 request/response digests; +the signature material contains no raw request or response content. A stream +that disconnects before completion creates no `resp_*` attestation record or +legacy disconnect fallback. + +Stateful operations are rejected: Conversations, response history, file input +and file search, function tools and function-call continuation, code +interpreter, computer, and every MCP approval mode other than +`require_approval: "never"`. ## 7. Troubleshooting From edbe3c3cba2585da7a95ad5c8e984e7a2e6b8244 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:35:00 +0800 Subject: [PATCH 12/31] test(responses): initialize service tier fixtures --- crates/services/src/responses/models.rs | 1 + crates/services/src/responses/service.rs | 1 + crates/services/src/responses/transient.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index 09ee19216..a095f9fb0 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -1408,6 +1408,7 @@ mod tests { metadata: None, safety_identifier: None, prompt_cache_key: None, + service_tier: None, } } diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index fefdaea4c..7f94ec818 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -3652,6 +3652,7 @@ mod tests { metadata: None, safety_identifier: None, prompt_cache_key: None, + service_tier: None, }; let response = response_repository .create(workspace_id, api_key_id, request) diff --git a/crates/services/src/responses/transient.rs b/crates/services/src/responses/transient.rs index 3e023ea5e..21b9cb8a0 100644 --- a/crates/services/src/responses/transient.rs +++ b/crates/services/src/responses/transient.rs @@ -500,6 +500,7 @@ mod tests { metadata: None, safety_identifier: None, prompt_cache_key: None, + service_tier: None, } } From f20cbbeeeb3ca19c45bc022e3d04686d32d803ec Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:11:17 +0800 Subject: [PATCH 13/31] feat(responses): support stateless client function replay --- crates/api/src/openapi.rs | 2 +- crates/api/tests/e2e_all/function_tools.rs | 330 +++++++++++- crates/services/src/responses/models.rs | 352 +++++++++++-- crates/services/src/responses/service.rs | 471 ++++++++++++------ .../services/src/responses/tools/function.rs | 25 +- docs/local-development.md | 29 +- 6 files changed, 973 insertions(+), 236 deletions(-) diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index e100e5983..80e3c5bb2 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -25,7 +25,7 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Responses", description = "Single-turn, stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Conversations, response history, file input/search, function/code-interpreter/computer tools, and MCP approval modes other than `require_approval: \"never\"` are rejected."), + (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Custom function tools are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Conversations, response history, file input/search, code-interpreter/computer tools, and MCP approval modes other than `require_approval: \"never\"` are rejected."), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), diff --git a/crates/api/tests/e2e_all/function_tools.rs b/crates/api/tests/e2e_all/function_tools.rs index 82093ac55..762f54d4e 100644 --- a/crates/api/tests/e2e_all/function_tools.rs +++ b/crates/api/tests/e2e_all/function_tools.rs @@ -1,13 +1,168 @@ -//! E2E coverage for retired client-executed function tooling. -//! -//! Stateless Responses requests cannot pause for a client to execute a -//! function and submit a continuation. Keep the boundary assertions here -//! instead of the former multi-turn lifecycle tests. +//! E2E coverage for client-managed function tools on stateless Responses. use crate::common::*; +use inference_providers::{ + mock::{RequestMatcher, ResponseTemplate, ToolCall}, + MessageRole, +}; +use services::responses::models::McpDiscoveredTool; +use services::responses::tools::{MockMcpClient, MockMcpClientFactory}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; #[tokio::test] -async fn function_tools_are_rejected_by_stateless_responses() { +async fn stateless_function_call_is_replayed_by_the_client_without_server_history() { + let (server, _pool, mock, _database) = setup_test_server_with_pool().await; + let model = setup_qwen_model(&server).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let prompt = "What is the weather in Shanghai?"; + mock.when(RequestMatcher::PromptWithTools { + prompt: mock_prompts::build_prompt(prompt), + tool_names: vec!["get_weather".to_string()], + }) + .respond_with( + ResponseTemplate::new("").with_tool_calls(vec![ToolCall::new( + "get_weather", + r#"{"location":"Shanghai"}"#, + )]), + ) + .await; + mock.set_default_response(ResponseTemplate::new( + "The temperature in Shanghai is 22°C.", + )) + .await; + + let tools = serde_json::json!([{ + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a location.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"] + } + }]); + + // First turn: Cloud returns a requested function call, but does not run it. + let first = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model, + "input": prompt, + "store": false, + "stream": false, + "tools": tools, + })) + .await; + assert_eq!( + first.status_code(), + 200, + "first turn failed: {}", + first.text() + ); + let first = first.json::(); + assert_eq!(first["status"], "incomplete"); + assert_eq!( + first["incomplete_details"]["reason"], + "function_call_required" + ); + let function_call = first["output"] + .as_array() + .expect("first response has output") + .iter() + .find(|item| item["type"] == "function_call") + .expect("first response returns a function_call") + .clone(); + let call_id = function_call["call_id"] + .as_str() + .expect("function call has call_id") + .to_string(); + + // The caller executes the function and sends both the returned call item + // and its result in a new stateless request. No response ID is referenced. + let second = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model, + "store": false, + "stream": false, + "input": [ + {"role": "user", "content": prompt}, + { + "type": "message", + "id": "msg_first_turn", + "status": "completed", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "I will look that up.", + "annotations": [], + "logprobs": [] + }] + }, + function_call, + { + "type": "function_call_output", + "call_id": call_id, + "output": "{\"temperature_c\":22}" + } + ], + "tools": tools, + })) + .await; + assert_eq!( + second.status_code(), + 200, + "second turn failed: {}", + second.text() + ); + let second = second.json::(); + assert_eq!(second["status"], "completed"); + + // The provider receives the raw assistant output text, then a standard + // assistant-tool-call and the client-produced tool result; Cloud did not + // execute the custom function. + let params = mock + .last_chat_params() + .await + .expect("second request reached provider"); + assert!(params.messages.iter().any(|message| { + message.role == MessageRole::Assistant + && message.tool_calls.is_none() + && message.content.as_ref() == Some(&serde_json::json!("I will look that up.")) + })); + let assistant = params + .messages + .iter() + .find(|message| { + message.role == MessageRole::Assistant && message.tool_calls.as_ref().is_some() + }) + .expect("provider received replayed assistant tool call"); + let tool_calls = assistant.tool_calls.as_ref().expect("tool calls present"); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].id.as_deref(), Some(call_id.as_str())); + assert_eq!(tool_calls[0].function.name.as_deref(), Some("get_weather")); + + let tool_result = params + .messages + .iter() + .find(|message| message.role == MessageRole::Tool) + .expect("provider received client-produced tool result"); + assert_eq!(tool_result.tool_call_id.as_deref(), Some(call_id.as_str())); + assert_eq!( + tool_result.content.as_ref(), + Some(&serde_json::json!("{\"temperature_c\":22}")) + ); +} + +#[tokio::test] +async fn stateless_function_replay_rejects_input_between_call_and_output() { let server = setup_test_server().await; let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; @@ -17,44 +172,175 @@ async fn function_tools_are_rejected_by_stateless_responses() { .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ "model": "test-model", - "input": "What is the weather?", "store": false, - "tools": [{ - "type": "function", - "name": "get_weather", - "parameters": {"type": "object"} - }] + "input": [ + { + "type": "function_call", + "call_id": "call_example", + "name": "get_weather", + "arguments": "{\"location\":\"Shanghai\"}" + }, + {"role": "user", "content": "This cannot interrupt the tool result."}, + { + "type": "function_call_output", + "call_id": "call_example", + "output": "{\"temperature\":22}" + } + ] })) .await; assert_eq!(response.status_code(), 400); let error = response.json::(); assert_eq!(error.error.r#type, "invalid_request_error"); - assert!(error.error.message.contains("function tools")); + assert!(error.error.message.contains("before any message")); } #[tokio::test] -async fn function_continuations_are_rejected_by_stateless_responses() { - let server = setup_test_server().await; +async fn stateless_custom_web_search_function_is_not_executed_as_a_builtin_tool() { + // Install a working built-in web-search provider. If the custom function + // below were accidentally claimed by the built-in executor, this response + // would continue after server-side search rather than pause for the client. + let (server, _database, mock) = setup_test_server_with_search_providers( + Arc::new(MockWebSearchProvider::default_results()), + None, + ) + .await; + let model = setup_qwen_model(&server).await; let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; + let prompt = "Search the web for the current weather in Shanghai."; + mock.when(RequestMatcher::PromptWithTools { + prompt: mock_prompts::build_prompt(prompt), + tool_names: vec!["web_search".to_string()], + }) + .respond_with( + ResponseTemplate::new("").with_tool_calls(vec![ToolCall::new( + "web_search", + r#"{"query":"Shanghai weather"}"#, + )]), + ) + .await; + let response = server .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ - "model": "test-model", + "model": model, + "input": prompt, "store": false, - "input": [{ - "type": "function_call_output", - "call_id": "call_example", - "output": "{\"temperature\":22}" + "stream": false, + "tools": [{ + "type": "function", + "name": "web_search", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } }] })) .await; - assert_eq!(response.status_code(), 400); + assert_eq!( + response.status_code(), + 200, + "response failed: {}", + response.text() + ); + let response = response.json::(); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "function_call_required" + ); + assert!(response["output"] + .as_array() + .expect("response has output") + .iter() + .any(|item| item["type"] == "function_call" && item["name"] == "web_search")); +} + +#[tokio::test] +async fn stateless_custom_function_colliding_with_discovered_mcp_tool_is_rejected_before_execution() +{ + let list_tools_calls = Arc::new(AtomicUsize::new(0)); + let mcp_tool_calls = Arc::new(AtomicUsize::new(0)); + let list_tools_calls_for_factory = list_tools_calls.clone(); + let mcp_tool_calls_for_factory = mcp_tool_calls.clone(); + + let mut mock_factory = MockMcpClientFactory::new(); + mock_factory + .expect_create_client() + .withf(|url: &str, _| url == "https://example.com/mcp") + .returning(move |_, _| { + let list_tools_calls = list_tools_calls_for_factory.clone(); + let mcp_tool_calls = mcp_tool_calls_for_factory.clone(); + let mut client = MockMcpClient::new(); + + client.expect_list_tools().returning(move || { + list_tools_calls.fetch_add(1, Ordering::SeqCst); + Ok(vec![McpDiscoveredTool { + name: "get_weather".to_string(), + description: Some("Get weather for a location".to_string()), + input_schema: Some(serde_json::json!({ + "type": "object", + "properties": {"location": {"type": "string"}} + })), + annotations: None, + }]) + }); + // The collision must be rejected before the model can cause an + // MCP call. Allow a call only so the counter produces a clear + // regression assertion rather than a mock expectation panic. + client.expect_call_tool().times(0..).returning(move |_, _| { + mcp_tool_calls.fetch_add(1, Ordering::SeqCst); + Ok("unexpected MCP execution".to_string()) + }); + + Ok(Box::new(client) as Box) + }); + + let (server, _pool, mock) = setup_test_server_with_mcp_factory(Arc::new(mock_factory)).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": "test-model", + "input": "What is the weather?", + "store": false, + "stream": false, + "tools": [ + { + "type": "function", + "name": "weather:get_weather", + "parameters": {"type": "object"} + }, + { + "type": "mcp", + "server_label": "weather", + "server_url": "https://example.com/mcp", + "require_approval": "never" + } + ] + })) + .await; + + assert_eq!(response.status_code(), 400, "response: {}", response.text()); let error = response.json::(); assert_eq!(error.error.r#type, "invalid_request_error"); - assert!(error.error.message.contains("function continuation")); + assert!(error + .error + .message + .contains("conflicts with a configured or discovered server-executed tool")); + assert_eq!(list_tools_calls.load(Ordering::SeqCst), 1); + assert_eq!(mcp_tool_calls.load(Ordering::SeqCst), 0); + assert!( + mock.last_chat_params().await.is_none(), + "the request must be rejected before inference" + ); } diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index a095f9fb0..624d88d71 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -111,6 +111,18 @@ pub enum ResponseInputItem { server_label: String, tools: Vec, }, + /// A function call returned by a prior stateless response and replayed by + /// the client with its output. The platform never executes this function. + FunctionCall { + #[serde(rename = "type")] + type_: FunctionCallType, + /// The call ID used to correlate this request with its output. + call_id: String, + /// The client-defined function name requested by the model. + name: String, + /// JSON-encoded arguments returned by the model. + arguments: String, + }, /// Output from a client-executed function call FunctionCallOutput { #[serde(rename = "type")] @@ -141,6 +153,7 @@ impl ResponseInputItem { ResponseInputItem::Message { role, .. } => Some(role), ResponseInputItem::McpApprovalResponse { .. } => None, ResponseInputItem::McpListTools { .. } => None, + ResponseInputItem::FunctionCall { .. } => None, ResponseInputItem::FunctionCallOutput { .. } => None, } } @@ -150,6 +163,7 @@ impl ResponseInputItem { ResponseInputItem::Message { content, .. } => Some(content), ResponseInputItem::McpApprovalResponse { .. } => None, ResponseInputItem::McpListTools { .. } => None, + ResponseInputItem::FunctionCall { .. } => None, ResponseInputItem::FunctionCallOutput { .. } => None, } } @@ -159,6 +173,7 @@ impl ResponseInputItem { ResponseInputItem::Message { metadata, .. } => metadata.as_ref(), ResponseInputItem::McpApprovalResponse { .. } => None, ResponseInputItem::McpListTools { .. } => None, + ResponseInputItem::FunctionCall { .. } => None, ResponseInputItem::FunctionCallOutput { .. } => None, } } @@ -176,6 +191,7 @@ impl ResponseInputItem { } => Some((approval_request_id, *approve)), ResponseInputItem::Message { .. } => None, ResponseInputItem::McpListTools { .. } => None, + ResponseInputItem::FunctionCall { .. } => None, ResponseInputItem::FunctionCallOutput { .. } => None, } } @@ -194,6 +210,13 @@ impl ResponseInputItem { } } +/// Type marker for a client-replayed function call input item. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +pub enum FunctionCallType { + #[serde(rename = "function_call")] + FunctionCall, +} + /// Type marker for MCP approval response input #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] pub enum McpApprovalResponseType { @@ -220,7 +243,7 @@ pub enum ResponseContent { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(tag = "type")] pub enum ResponseContentPart { - #[serde(rename = "input_text")] + #[serde(rename = "input_text", alias = "output_text")] InputText { text: String }, #[serde(rename = "input_image")] InputImage { @@ -1229,9 +1252,10 @@ impl CreateResponseRequest { /// Validate that a request can be handled without platform-side response /// or conversation persistence. /// - /// The Responses API is intentionally limited to a single request/response - /// interaction. Clients that need a multi-turn flow must include the full - /// context in the new request instead of referring to stored server state. + /// The Responses API does not retain conversation state. Clients that need + /// a multi-turn flow must include the full context in a new request instead + /// of referring to stored server state. This includes replaying a custom + /// function call and its client-produced output in the same request. pub fn validate_stateless(&self) -> Result<(), String> { if self.store == Some(true) { return Err("The Responses API only supports store: false.".to_string()); @@ -1252,6 +1276,11 @@ impl CreateResponseRequest { } if let Some(ResponseInput::Items(items)) = &self.input { + let mut replayed_function_call_ids = HashSet::new(); + let mut completed_function_call_ids = HashSet::new(); + let mut pending_function_call_ids = HashSet::new(); + let mut receiving_function_outputs = false; + for item in items { match item { ResponseInputItem::McpApprovalResponse { .. } => { @@ -1260,42 +1289,104 @@ impl CreateResponseRequest { .to_string(), ); } - ResponseInputItem::FunctionCallOutput { .. } => { - return Err( - "The stateless Responses API does not support function continuation." - .to_string(), - ); + ResponseInputItem::FunctionCall { call_id, name, .. } => { + if receiving_function_outputs { + return Err( + "A replayed function_call block must list all function_call items before any function_call_output." + .to_string(), + ); + } + if call_id.trim().is_empty() { + return Err( + "A replayed function_call must include call_id.".to_string() + ); + } + if name.trim().is_empty() { + return Err("A replayed function_call must include name.".to_string()); + } + if !replayed_function_call_ids.insert(call_id.clone()) { + return Err(format!( + "duplicate call_id '{call_id}' in replayed function_call items" + )); + } + pending_function_call_ids.insert(call_id.clone()); } - ResponseInputItem::Message { - content: ResponseContent::Parts(parts), - .. - } if parts - .iter() - .any(|part| matches!(part, ResponseContentPart::InputFile { .. })) => + ResponseInputItem::FunctionCallOutput { call_id, .. } => { + if call_id.trim().is_empty() { + return Err("A function_call_output must include call_id.".to_string()); + } + if !completed_function_call_ids.insert(call_id.clone()) { + return Err(format!( + "duplicate call_id '{call_id}' in function_call_output items" + )); + } + if !pending_function_call_ids.remove(call_id) { + return Err(format!( + "function_call_output for call_id '{call_id}' must follow a matching function_call in the same stateless request" + )); + } + receiving_function_outputs = !pending_function_call_ids.is_empty(); + } + ResponseInputItem::Message { content, .. } => { + if !pending_function_call_ids.is_empty() { + return Err( + "A replayed function_call block must be followed by its matching function_call_output items before any message." + .to_string(), + ); + } + if let ResponseContent::Parts(parts) = content { + if parts + .iter() + .any(|part| matches!(part, ResponseContentPart::InputFile { .. })) + { + return Err( + "The stateless Responses API does not support input_file." + .to_string(), + ); + } + } + } + ResponseInputItem::McpListTools { .. } + if !pending_function_call_ids.is_empty() => { return Err( - "The stateless Responses API does not support input_file.".to_string() + "A replayed function_call block must be followed by its matching function_call_output items before any other input item." + .to_string(), ); } _ => {} } } + + if !pending_function_call_ids.is_empty() { + return Err( + "Each replayed function_call must have a matching function_call_output in the same stateless request." + .to_string(), + ); + } } if let Some(tools) = &self.tools { + let mut custom_function_names = HashSet::new(); + let mut configured_builtin_names = HashSet::new(); + for tool in tools { match tool { + ResponseTool::Function { name, .. } => { + custom_function_names.insert(name.as_str()); + } + ResponseTool::WebSearch { .. } => { + configured_builtin_names.insert("web_search"); + } + ResponseTool::WebContextSearch {} => { + configured_builtin_names.insert("web_context_search"); + } ResponseTool::FileSearch { .. } => { + configured_builtin_names.insert("file_search"); return Err( "The stateless Responses API does not support file_search.".to_string() ); } - ResponseTool::Function { .. } => { - return Err( - "The stateless Responses API does not support function tools because they require continuation." - .to_string(), - ); - } ResponseTool::CodeInterpreter {} => { return Err( "The stateless Responses API does not support code_interpreter because it requires continuation." @@ -1323,6 +1414,15 @@ impl CreateResponseRequest { _ => {} } } + + if let Some(name) = custom_function_names + .iter() + .find(|name| configured_builtin_names.contains(*name)) + { + return Err(format!( + "Custom function '{name}' conflicts with a configured built-in tool of the same name." + )); + } } Ok(()) @@ -1412,6 +1512,29 @@ mod tests { } } + fn replayed_function_call(call_id: &str) -> ResponseInputItem { + ResponseInputItem::FunctionCall { + type_: FunctionCallType::FunctionCall, + call_id: call_id.to_string(), + name: "lookup".to_string(), + arguments: "{}".to_string(), + } + } + + fn function_call_output(call_id: &str) -> ResponseInputItem { + ResponseInputItem::FunctionCallOutput { + type_: FunctionCallOutputType::FunctionCallOutput, + call_id: call_id.to_string(), + output: "{}".to_string(), + } + } + + fn stateless_request_with_items(items: Vec) -> CreateResponseRequest { + let mut request = stateless_request(); + request.input = Some(ResponseInput::Items(items)); + request + } + #[test] fn test_response_status_serializes_in_progress_with_underscore() { assert_eq!( @@ -2066,6 +2189,126 @@ mod tests { assert!(explicit_no_store.validate_stateless().is_ok()); } + #[test] + fn stateless_function_call_replay_accepts_returned_output_without_server_state() { + // This is the second request in a client-managed two-turn loop. The + // `function_call` object is shaped like the first response's output + // item, including fields the input parser intentionally ignores. + let request: CreateResponseRequest = serde_json::from_value(json!({ + "model": "gpt-4", + "store": false, + "input": [ + {"role": "user", "content": "What is the weather?"}, + { + "type": "message", + "id": "msg_first_turn", + "status": "completed", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "I will look that up.", + "annotations": [], + "logprobs": [] + }] + }, + { + "type": "function_call", + "id": "fc_first_turn", + "response_id": "resp_first_turn", + "created_at": 0, + "status": "in_progress", + "model": "gpt-4", + "call_id": "call_weather", + "name": "get_weather", + "arguments": "{\"location\":\"Shanghai\"}" + }, + { + "type": "function_call_output", + "call_id": "call_weather", + "output": "{\"temperature_c\":22}" + } + ], + "tools": [{ + "type": "function", + "name": "get_weather", + "parameters": {"type": "object"} + }] + })) + .expect("client replay request should deserialize"); + + assert!(request.previous_response_id.is_none()); + assert!(request.conversation.is_none()); + assert!(request.validate_stateless().is_ok()); + assert!(matches!( + request.input, + Some(ResponseInput::Items(ref items)) + if matches!(items[1], ResponseInputItem::Message { + content: ResponseContent::Parts(ref parts), + .. + } if matches!(parts[0], ResponseContentPart::InputText { .. })) + && matches!(items[2], ResponseInputItem::FunctionCall { .. }) + && matches!(items[3], ResponseInputItem::FunctionCallOutput { .. }) + )); + } + + #[test] + fn stateless_function_replay_validates_contiguous_call_output_blocks() { + let duplicate_call = stateless_request_with_items(vec![ + replayed_function_call("call_one"), + replayed_function_call("call_one"), + function_call_output("call_one"), + ]); + assert!(duplicate_call + .validate_stateless() + .unwrap_err() + .contains("duplicate call_id 'call_one' in replayed function_call")); + + let duplicate_output = stateless_request_with_items(vec![ + replayed_function_call("call_one"), + function_call_output("call_one"), + function_call_output("call_one"), + ]); + assert!(duplicate_output + .validate_stateless() + .unwrap_err() + .contains("duplicate call_id 'call_one' in function_call_output")); + + let orphan_output = stateless_request_with_items(vec![ + replayed_function_call("call_one"), + function_call_output("call_two"), + ]); + assert!(orphan_output + .validate_stateless() + .unwrap_err() + .contains("matching function_call")); + + let missing_output = stateless_request_with_items(vec![replayed_function_call("call_one")]); + assert!(missing_output + .validate_stateless() + .unwrap_err() + .contains("Each replayed function_call must have a matching")); + + let parallel_calls = stateless_request_with_items(vec![ + replayed_function_call("call_one"), + replayed_function_call("call_two"), + function_call_output("call_one"), + function_call_output("call_two"), + ]); + assert!(parallel_calls.validate_stateless().is_ok()); + + let call_after_outputs_start = stateless_request_with_items(vec![ + replayed_function_call("call_one"), + replayed_function_call("call_two"), + function_call_output("call_one"), + replayed_function_call("call_three"), + function_call_output("call_two"), + ]); + assert!(call_after_outputs_start + .validate_stateless() + .unwrap_err() + .contains("before any function_call_output")); + } + #[test] fn stateless_requests_reject_persistent_response_fields() { let mut store = stateless_request(); @@ -2113,18 +2356,42 @@ mod tests { .unwrap_err() .contains("input_file")); - let mut function_continuation = stateless_request(); - function_continuation.input = Some(ResponseInput::Items(vec![ + let mut missing_function_call = stateless_request(); + missing_function_call.input = Some(ResponseInput::Items(vec![ + ResponseInputItem::FunctionCallOutput { + type_: FunctionCallOutputType::FunctionCallOutput, + call_id: "call_test".to_string(), + output: "{}".to_string(), + }, + ])); + assert!(missing_function_call + .validate_stateless() + .unwrap_err() + .contains("matching function_call")); + + let mut interleaved_function_result = stateless_request(); + interleaved_function_result.input = Some(ResponseInput::Items(vec![ + ResponseInputItem::FunctionCall { + type_: FunctionCallType::FunctionCall, + call_id: "call_test".to_string(), + name: "lookup".to_string(), + arguments: "{}".to_string(), + }, + ResponseInputItem::Message { + role: "user".to_string(), + content: ResponseContent::Text("interleaved input".to_string()), + metadata: None, + }, ResponseInputItem::FunctionCallOutput { type_: FunctionCallOutputType::FunctionCallOutput, call_id: "call_test".to_string(), output: "{}".to_string(), }, ])); - assert!(function_continuation + assert!(interleaved_function_result .validate_stateless() .unwrap_err() - .contains("function continuation")); + .contains("before any message")); let mut mcp_approval = stateless_request(); mcp_approval.input = Some(ResponseInput::Items(vec![ @@ -2146,17 +2413,6 @@ mod tests { .unwrap_err() .contains("file_search")); - let mut function_tool = stateless_request(); - function_tool.tools = Some(vec![ResponseTool::Function { - name: "lookup".to_string(), - description: None, - parameters: None, - }]); - assert!(function_tool - .validate_stateless() - .unwrap_err() - .contains("function tools")); - let mut code_interpreter = stateless_request(); code_interpreter.tools = Some(vec![ResponseTool::CodeInterpreter {}]); assert!(code_interpreter @@ -2185,4 +2441,26 @@ mod tests { .unwrap_err() .contains("require approval")); } + + #[test] + fn stateless_requests_reject_custom_function_name_colliding_with_builtin_tool() { + let mut request = stateless_request(); + request.tools = Some(vec![ + ResponseTool::Function { + name: "web_search".to_string(), + description: None, + parameters: None, + }, + ResponseTool::WebSearch { + filters: None, + search_context_size: None, + user_location: None, + }, + ]); + + assert!(request + .validate_stateless() + .unwrap_err() + .contains("conflicts with a configured built-in tool")); + } } diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index 7f94ec818..901934071 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -167,6 +167,26 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { request.store = Some(false); request.background = Some(false); + // Only install server-side executors for built-in tools explicitly + // requested by this call. In particular, a client-defined function + // named `web_search` must remain client-managed rather than being + // claimed by the built-in executor. + let has_web_search = request.tools.as_ref().map_or(false, |tools| { + tools + .iter() + .any(|tool| matches!(tool, models::ResponseTool::WebSearch { .. })) + }); + let has_web_context_search = request.tools.as_ref().map_or(false, |tools| { + tools + .iter() + .any(|tool| matches!(tool, models::ResponseTool::WebContextSearch {})) + }); + let has_file_search = request.tools.as_ref().map_or(false, |tools| { + tools + .iter() + .any(|tool| matches!(tool, models::ResponseTool::FileSearch {})) + }); + // Create a channel for streaming events let (mut tx, rx) = mpsc::unbounded::(); @@ -191,15 +211,21 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { tokio::spawn(async move { let mut tool_registry = tools::ToolRegistry::new(); - if let Some(provider) = web_search_provider { - tool_registry.register(Arc::new(tools::WebSearchToolExecutor::new(provider))); + if has_web_search { + if let Some(provider) = web_search_provider { + tool_registry.register(Arc::new(tools::WebSearchToolExecutor::new(provider))); + } } - if let Some(provider) = web_context_search_provider { - tool_registry - .register(Arc::new(tools::WebContextSearchToolExecutor::new(provider))); + if has_web_context_search { + if let Some(provider) = web_context_search_provider { + tool_registry + .register(Arc::new(tools::WebContextSearchToolExecutor::new(provider))); + } } - if let Some(provider) = file_search_provider { - tool_registry.register(Arc::new(tools::FileSearchToolExecutor::new(provider))); + if has_file_search { + if let Some(provider) = file_search_provider { + tool_registry.register(Arc::new(tools::FileSearchToolExecutor::new(provider))); + } } // Note: MCP executor is added later after connecting to servers @@ -291,6 +317,58 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { } impl ResponseServiceImpl { + /// Reject custom function names that conflict with a tool Cloud executes. + /// + /// Fixed built-ins are known during request validation; discovered MCP + /// names must be checked here, after MCP setup and before inference or + /// executor registration. + fn validate_custom_function_tool_name_collisions( + request: &models::CreateResponseRequest, + mcp_tool_definitions: &[inference_providers::ToolDefinition], + ) -> Result<(), errors::ResponseError> { + let mut server_executed_tool_names = HashSet::new(); + + if let Some(configured_tools) = &request.tools { + for tool in configured_tools { + match tool { + models::ResponseTool::WebSearch { .. } => { + server_executed_tool_names.insert(tools::WEB_SEARCH_TOOL_NAME.to_string()); + } + models::ResponseTool::WebContextSearch {} => { + server_executed_tool_names + .insert(tools::WEB_CONTEXT_SEARCH_TOOL_NAME.to_string()); + } + models::ResponseTool::FileSearch {} => { + server_executed_tool_names.insert(tools::FILE_SEARCH_TOOL_NAME.to_string()); + } + _ => {} + } + } + } + + server_executed_tool_names.extend( + mcp_tool_definitions + .iter() + .map(|definition| definition.function.name.clone()), + ); + + let Some(configured_tools) = &request.tools else { + return Ok(()); + }; + + for tool in configured_tools { + if let models::ResponseTool::Function { name, .. } = tool { + if server_executed_tool_names.contains(name) { + return Err(errors::ResponseError::InvalidParams(format!( + "Custom function '{name}' conflicts with a configured or discovered server-executed tool of the same name." + ))); + } + } + } + + Ok(()) + } + /// Parse file ID from string (handles prefix) fn parse_file_id(file_id: &str) -> Result { let id_str = file_id @@ -930,17 +1008,6 @@ impl ResponseServiceImpl { let workspace_id_domain = crate::workspace::WorkspaceId(context.workspace_id); - // Validate function call outputs early (before creating response) so invalid - // FunctionCallOutput items never get persisted. Required before load_conversation_context - // so we can pass validated tool messages for correct ordering. - let function_output_messages = Self::process_function_call_outputs( - &context.request, - &context.response_items_repository, - &context.response_repository, - context.workspace_id, - ) - .await?; - // Verify previous_response_id ownership before loading any history or // creating the response. Unknown and foreign response IDs get the same // non-enumerating 404-style error; without this check a foreign ID @@ -990,7 +1057,6 @@ impl ResponseServiceImpl { context.organization_id, context.user_id.clone(), &context.organization_service, - &function_output_messages, ) .await?; @@ -1094,6 +1160,13 @@ impl ResponseServiceImpl { ) .await? { + // MCP tool names are only known after discovery. Reject a custom + // function that would otherwise be claimed by the MCP executor, + // which is registered before the custom-function executor. + Self::validate_custom_function_tool_name_collisions( + &context.request, + &mcp_setup.tool_definitions, + )?; tools.extend(mcp_setup.tool_definitions); context.tool_registry.register(mcp_setup.executor.clone()); context.mcp_executor = Some(mcp_setup.executor); @@ -1916,130 +1989,6 @@ impl ResponseServiceImpl { Ok(()) } - /// Process function call outputs from the request input. - /// - /// When resuming a response after function calls, the client provides - /// FunctionCallOutput items with the results. This function: - /// 1. Extracts FunctionCallOutput items from the request input - /// 2. Verifies `previous_response_id` belongs to the caller's workspace - /// (workspace-scoped ownership check, prevents cross-workspace IDOR) - /// 3. Fetches stored FunctionCall items and validates each submitted - /// `call_id` matches exactly one FunctionCall (rejects zero or ambiguous matches) - /// 4. Creates tool result messages for the conversation context - /// - /// Returns messages to add to the conversation context. - async fn process_function_call_outputs( - request: &models::CreateResponseRequest, - response_items_repository: &Arc, - response_repository: &Arc, - workspace_id: uuid::Uuid, - ) -> Result, errors::ResponseError> { - use crate::completions::ports::CompletionMessage; - - let mut messages = Vec::new(); - - // Extract function call outputs from input - let function_outputs: Vec<_> = match &request.input { - Some(models::ResponseInput::Items(items)) => items - .iter() - .filter_map(|item| item.as_function_call_output()) - .collect(), - _ => return Ok(messages), - }; - - if function_outputs.is_empty() { - return Ok(messages); - } - - // Function call outputs are only valid when resuming a previous response. - let prev_response_id = request.previous_response_id.as_ref().ok_or_else(|| { - errors::ResponseError::InvalidParams( - "function_call_output requires previous_response_id to resume a response" - .to_string(), - ) - })?; - - let uuid_str = prev_response_id - .strip_prefix(crate::id_prefixes::PREFIX_RESP) - .unwrap_or(prev_response_id); - - let response_uuid = uuid::Uuid::parse_str(uuid_str).map_err(|e| { - errors::ResponseError::InvalidParams(format!("invalid previous_response_id: {e}")) - })?; - - // Verify the previous response belongs to this workspace (prevents IDOR) - response_repository - .get_by_id( - models::ResponseId(response_uuid), - crate::workspace::WorkspaceId(workspace_id), - ) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!( - "Failed to verify previous response ownership: {e}" - )) - })? - .ok_or_else(|| { - errors::ResponseError::FunctionCallNotFound( - "previous_response_id not found in this workspace".to_string(), - ) - })?; - - let prev_items = response_items_repository - .list_by_response(models::ResponseId(response_uuid)) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!("Failed to fetch response items: {e}")) - })?; - - let mut seen_call_ids = std::collections::HashSet::new(); - for (call_id, output) in function_outputs { - if !seen_call_ids.insert(call_id) { - return Err(errors::ResponseError::InvalidParams(format!( - "duplicate call_id '{}' in function_call_output items", - call_id - ))); - } - - let match_count = prev_items - .iter() - .filter(|item| { - matches!(item, models::ResponseOutputItem::FunctionCall { - call_id: item_call_id, - .. - } if item_call_id == call_id) - }) - .count(); - - if match_count == 0 { - return Err(errors::ResponseError::FunctionCallNotFound( - call_id.to_string(), - )); - } - if match_count > 1 { - return Err(errors::ResponseError::InvalidParams(format!( - "ambiguous call_id '{}' matches {} function calls", - call_id, match_count - ))); - } - - // Create tool result message with the function output - messages.push(CompletionMessage { - role: "tool".to_string(), - content: serde_json::Value::String(output.to_string()), - tool_call_id: Some(call_id.to_string()), - tool_calls: None, - }); - } - - tracing::debug!( - "Processed {} function call outputs into tool result messages", - messages.len() - ); - - Ok(messages) - } - /// Store request input as response items and return the IDs that originated /// from the client. The IDs are kept only for this request and ensure that /// historical assistant messages are never returned as new output. @@ -2105,6 +2054,13 @@ impl ResponseServiceImpl { models::ResponseInputItem::McpListTools { .. } => { continue; } + // Replayed function calls only reconstruct the provider + // context below. They are not part of this response's + // output and must not be returned as newly generated + // items. + models::ResponseInputItem::FunctionCall { .. } => { + continue; + } models::ResponseInputItem::FunctionCallOutput { call_id, output, .. } => { @@ -2235,11 +2191,64 @@ impl ResponseServiceImpl { .collect() } + /// Flush replayed function calls into the assistant message shape expected + /// by Chat Completions providers. The calls remain entirely client-owned: + /// this only reconstructs supplied context for the current request. + fn flush_replayed_function_calls( + messages: &mut Vec, + pending_function_calls: &mut Vec, + ) { + if pending_function_calls.is_empty() { + return; + } + + messages.push(crate::completions::ports::CompletionMessage { + role: "assistant".to_string(), + content: serde_json::Value::String(String::new()), + tool_call_id: None, + tool_calls: Some(std::mem::take(pending_function_calls)), + }); + } + + /// Append one client-replayed function item to the request's completion + /// context. Returns true when the item was handled. + fn append_replayed_function_call_item( + input_item: &models::ResponseInputItem, + messages: &mut Vec, + pending_function_calls: &mut Vec, + ) -> bool { + match input_item { + models::ResponseInputItem::FunctionCall { + call_id, + name, + arguments, + .. + } => { + pending_function_calls.push(crate::completions::ports::CompletionToolCall { + id: call_id.clone(), + name: name.clone(), + arguments: arguments.clone(), + thought_signature: None, + }); + true + } + models::ResponseInputItem::FunctionCallOutput { + call_id, output, .. + } => { + Self::flush_replayed_function_calls(messages, pending_function_calls); + messages.push(crate::completions::ports::CompletionMessage { + role: "tool".to_string(), + content: serde_json::Value::String(output.clone()), + tool_call_id: Some(call_id.clone()), + tool_calls: None, + }); + true + } + _ => false, + } + } + /// Load conversation context based on conversation_id or previous_response_id. - /// - /// When `function_output_messages` is provided (resume with FunctionCallOutput), they are - /// interleaved with Message input items in request order so tool results immediately follow - /// the assistant message containing tool_calls, as required by LLM providers. #[allow(clippy::too_many_arguments)] async fn load_conversation_context( request: &models::CreateResponseRequest, @@ -2250,7 +2259,6 @@ impl ResponseServiceImpl { organization_id: uuid::Uuid, user_id: crate::UserId, organization_service: &Arc, - function_output_messages: &[crate::completions::ports::CompletionMessage], ) -> Result, errors::ResponseError> { use crate::completions::ports::CompletionMessage; @@ -2638,10 +2646,22 @@ impl ResponseServiceImpl { }); } models::ResponseInput::Items(items) => { - let mut fco_idx = 0; + let mut pending_function_calls = Vec::new(); for item in items { + if Self::append_replayed_function_call_item( + item, + &mut messages, + &mut pending_function_calls, + ) { + continue; + } + match item { models::ResponseInputItem::Message { role, content, .. } => { + Self::flush_replayed_function_calls( + &mut messages, + &mut pending_function_calls, + ); let content = match content { models::ResponseContent::Text(text) => { serde_json::Value::String(text.clone()) @@ -2662,16 +2682,13 @@ impl ResponseServiceImpl { tool_calls: None, }); } - models::ResponseInputItem::FunctionCallOutput { .. } => { - if fco_idx < function_output_messages.len() { - messages.push(function_output_messages[fco_idx].clone()); - fco_idx += 1; - } - } models::ResponseInputItem::McpApprovalResponse { .. } - | models::ResponseInputItem::McpListTools { .. } => {} + | models::ResponseInputItem::McpListTools { .. } + | models::ResponseInputItem::FunctionCall { .. } + | models::ResponseInputItem::FunctionCallOutput { .. } => {} } } + Self::flush_replayed_function_calls(&mut messages, &mut pending_function_calls); } } } @@ -3714,6 +3731,148 @@ mod tests { assert_eq!(output_items[0].id(), generated_item_id); } + #[test] + fn replayed_function_call_and_output_rebuild_client_managed_tool_context() { + // This path deliberately uses no repository or function executor: the + // client supplies both items in a fresh request and Cloud only rebuilds + // the provider message sequence. + let items = vec![ + models::ResponseInputItem::FunctionCall { + type_: models::FunctionCallType::FunctionCall, + call_id: "call_weather".to_string(), + name: "get_weather".to_string(), + arguments: r#"{"location":"Shanghai"}"#.to_string(), + }, + models::ResponseInputItem::FunctionCallOutput { + type_: models::FunctionCallOutputType::FunctionCallOutput, + call_id: "call_weather".to_string(), + output: r#"{"temperature_c":22}"#.to_string(), + }, + ]; + + let mut messages = Vec::new(); + let mut pending_function_calls = Vec::new(); + for item in &items { + assert!(ResponseServiceImpl::append_replayed_function_call_item( + item, + &mut messages, + &mut pending_function_calls, + )); + } + ResponseServiceImpl::flush_replayed_function_calls( + &mut messages, + &mut pending_function_calls, + ); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, "assistant"); + let tool_calls = messages[0] + .tool_calls + .as_ref() + .expect("replayed function call becomes an assistant tool call"); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].id, "call_weather"); + assert_eq!(tool_calls[0].name, "get_weather"); + assert_eq!(tool_calls[0].arguments, r#"{"location":"Shanghai"}"#); + + assert_eq!(messages[1].role, "tool"); + assert_eq!(messages[1].tool_call_id.as_deref(), Some("call_weather")); + assert_eq!( + messages[1].content, + serde_json::json!(r#"{"temperature_c":22}"#) + ); + } + + #[test] + fn parallel_replayed_function_calls_are_grouped_before_their_outputs() { + let items = vec![ + models::ResponseInputItem::FunctionCall { + type_: models::FunctionCallType::FunctionCall, + call_id: "call_weather".to_string(), + name: "get_weather".to_string(), + arguments: r#"{"location":"Shanghai"}"#.to_string(), + }, + models::ResponseInputItem::FunctionCall { + type_: models::FunctionCallType::FunctionCall, + call_id: "call_time".to_string(), + name: "get_time".to_string(), + arguments: r#"{"timezone":"Asia/Shanghai"}"#.to_string(), + }, + models::ResponseInputItem::FunctionCallOutput { + type_: models::FunctionCallOutputType::FunctionCallOutput, + call_id: "call_weather".to_string(), + output: r#"{"temperature_c":22}"#.to_string(), + }, + models::ResponseInputItem::FunctionCallOutput { + type_: models::FunctionCallOutputType::FunctionCallOutput, + call_id: "call_time".to_string(), + output: r#"{"time":"12:00"}"#.to_string(), + }, + ]; + + let mut messages = Vec::new(); + let mut pending_function_calls = Vec::new(); + for item in &items { + assert!(ResponseServiceImpl::append_replayed_function_call_item( + item, + &mut messages, + &mut pending_function_calls, + )); + } + ResponseServiceImpl::flush_replayed_function_calls( + &mut messages, + &mut pending_function_calls, + ); + + assert_eq!(messages.len(), 3); + let tool_calls = messages[0] + .tool_calls + .as_ref() + .expect("parallel calls are grouped in one assistant message"); + assert_eq!( + tool_calls + .iter() + .map(|call| call.id.as_str()) + .collect::>(), + vec!["call_weather", "call_time"] + ); + assert_eq!(messages[1].tool_call_id.as_deref(), Some("call_weather")); + assert_eq!(messages[2].tool_call_id.as_deref(), Some("call_time")); + } + + #[test] + fn custom_function_name_colliding_with_discovered_mcp_tool_is_rejected() { + let request: models::CreateResponseRequest = serde_json::from_value(serde_json::json!({ + "model": "test-model", + "store": false, + "tools": [{ + "type": "function", + "name": "weather:get_weather", + "parameters": {"type": "object"} + }] + })) + .expect("test request should deserialize"); + let mcp_tool_definitions = vec![inference_providers::ToolDefinition { + type_: "function".to_string(), + function: inference_providers::FunctionDefinition { + name: "weather:get_weather".to_string(), + description: None, + parameters: serde_json::json!({"type": "object"}), + }, + }]; + + let error = ResponseServiceImpl::validate_custom_function_tool_name_collisions( + &request, + &mcp_tool_definitions, + ) + .expect_err("same-name custom function must not be claimed by MCP"); + + assert!(matches!(error, errors::ResponseError::InvalidParams(_))); + assert!(error + .to_string() + .contains("conflicts with a configured or discovered server-executed tool")); + } + #[test] fn test_process_reasoning_tags_simple_think() { let mut reasoning_buffer = String::new(); diff --git a/crates/services/src/responses/tools/function.rs b/crates/services/src/responses/tools/function.rs index 12bb43c78..5f85aa332 100644 --- a/crates/services/src/responses/tools/function.rs +++ b/crates/services/src/responses/tools/function.rs @@ -9,19 +9,17 @@ //! 2. LLM calls the function with arguments //! 3. Server returns response with status=incomplete, reason="function_call_required" //! 4. Client executes the function externally -//! 5. Client submits function output via FunctionCallOutput input -//! with `previous_response_id` pointing to the incomplete response -//! 6. Server verifies `previous_response_id` belongs to the caller's workspace, -//! matches each `call_id` to exactly one stored FunctionCall, then resumes -//! the response with function results in context +//! 5. Client sends a new `store: false` request containing the returned +//! `function_call` item and the matching `function_call_output` item +//! 6. Server reconstructs the supplied assistant/tool context for that request +//! without reading response history or executing the function //! //! Security invariants: //! - Every FunctionCall gets a unique `call_id`. If the LLM omits one, the //! executor generates a `call_` identifier to prevent ambiguous matching. -//! - `process_function_call_outputs` enforces workspace-scoped ownership on -//! `previous_response_id` before fetching any items (prevents cross-workspace IDOR). -//! - Each submitted `call_id` must match exactly one stored FunctionCall; -//! zero matches or duplicate matches are rejected. +//! - Stateless continuation validation requires each output to follow exactly +//! one replayed function call in the same request; missing or duplicate IDs +//! are rejected. use async_trait::async_trait; use std::collections::HashSet; @@ -46,8 +44,9 @@ impl FunctionToolExecutor { /// Create a new FunctionToolExecutor from a request /// /// Uses the canonical list of client-executed tool names from - /// [super::get_function_tool_names]. These are tools the client must execute - /// externally (Function, CodeInterpreter, Computer). + /// [super::get_function_tool_names]. The executor is shared with legacy + /// paths, but stateless Responses validation admits only custom + /// `ResponseTool::Function` tools. pub fn new(request: &CreateResponseRequest) -> Self { let function_names = super::get_function_tool_names(request) .into_iter() @@ -128,7 +127,9 @@ impl ToolExecutor for FunctionToolExecutor { model: event_ctx.stream_ctx.model.clone(), }; - // Store the function call in the database + // Keep the function call in the request-scoped response store + // so it is emitted in this response. The stateless route never + // uses a persistent response-item repository. if let Some(repo) = &event_ctx.response_items_repository { if let Err(e) = repo .create( diff --git a/docs/local-development.md b/docs/local-development.md index b6e94a29b..4a6c4bfe6 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -272,7 +272,7 @@ Provider refresh runs every 300s by default | `POST /v1/workspaces/{id}/api-keys` | session | Returns plaintext `key` — store it, it isn't shown again | | `GET /v1/models` | public | OpenAI-compatible model catalog with pricing metadata | | `POST /v1/chat/completions` | API key | OpenAI-compatible. Add `"stream": true` for SSE | -| `POST /v1/responses` | API key | Single-turn `store: false` inference; response history is unavailable | +| `POST /v1/responses` | API key | Stateless `store: false` inference; response history is unavailable | | `GET /v1/attestation/report` | API key | TEE attestation (503 outside a CVM unless `DEV=true` in debug builds) | | `GET /v1/attestation/ita-token` | public | Intel Trust Authority JWT wrapper (requires ITA env vars) | | `GET /v1/signature/{chat_id}` | API key | Per-completion and `resp_*` signature lookup | @@ -282,10 +282,24 @@ interactively and inspect request/response schemas. ### Stateless Responses and attestation retention -`POST /v1/responses` is single-turn and accepts only `store: false` (an omitted -value is treated as `false`). Cloud API does not retain raw request or response -content, response items, or response history; clients must supply any needed -prior context with each request. +`POST /v1/responses` accepts only `store: false` (an omitted value is treated +as `false`). Cloud API does not retain raw request or response content, +response items, or response history; clients must supply any needed prior +context with each request. + +Custom `function` tools are client-managed. Cloud returns a `function_call` +item but does not execute it. To continue after running the function, the +client sends a fresh `store: false` request containing the original +`function_call` and its matching `function_call_output`; it must not use +`previous_response_id` or `conversation`. Send the same custom function tool +definitions again in `tools` on every request so the model can continue to +call them if needed. + +This initial compatibility path accepts a raw `function_call` item from a +previous response, its matching `function_call_output`, and assistant +`message` content parts of type `output_text` as caller-managed message +history. It does not support reasoning items or arbitrary full +`response.output` replay; reasoning-model compatibility is separate work. Existing completed-response gateway attestation is preserved on a best-effort basis. When its signature write succeeds, `GET /v1/signature/resp_*` can @@ -295,9 +309,8 @@ that disconnects before completion creates no `resp_*` attestation record or legacy disconnect fallback. Stateful operations are rejected: Conversations, response history, file input -and file search, function tools and function-call continuation, code -interpreter, computer, and every MCP approval mode other than -`require_approval: "never"`. +and file search, code interpreter, computer, and every MCP approval mode other +than `require_approval: "never"`. ## 7. Troubleshooting From 2351829d5d904c6701b6d3d3cdbd634bdf5df4ff Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:18:08 +0800 Subject: [PATCH 14/31] fix(responses): satisfy clippy stateless tool checks --- crates/services/src/responses/service.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index 901934071..bd0c3f8d7 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -171,17 +171,17 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { // requested by this call. In particular, a client-defined function // named `web_search` must remain client-managed rather than being // claimed by the built-in executor. - let has_web_search = request.tools.as_ref().map_or(false, |tools| { + let has_web_search = request.tools.as_ref().is_some_and(|tools| { tools .iter() .any(|tool| matches!(tool, models::ResponseTool::WebSearch { .. })) }); - let has_web_context_search = request.tools.as_ref().map_or(false, |tools| { + let has_web_context_search = request.tools.as_ref().is_some_and(|tools| { tools .iter() .any(|tool| matches!(tool, models::ResponseTool::WebContextSearch {})) }); - let has_file_search = request.tools.as_ref().map_or(false, |tools| { + let has_file_search = request.tools.as_ref().is_some_and(|tools| { tools .iter() .any(|tool| matches!(tool, models::ResponseTool::FileSearch {})) From 31e98c94523ffe27093c4d616ea9e0a0a1e0eaac Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:52:10 +0800 Subject: [PATCH 15/31] fix(responses): allow interleaved function replay --- crates/api/tests/e2e_all/function_tools.rs | 35 ----------- crates/services/src/responses/models.rs | 67 +++++----------------- 2 files changed, 15 insertions(+), 87 deletions(-) diff --git a/crates/api/tests/e2e_all/function_tools.rs b/crates/api/tests/e2e_all/function_tools.rs index 762f54d4e..60269566d 100644 --- a/crates/api/tests/e2e_all/function_tools.rs +++ b/crates/api/tests/e2e_all/function_tools.rs @@ -161,41 +161,6 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor ); } -#[tokio::test] -async fn stateless_function_replay_rejects_input_between_call_and_output() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "test-model", - "store": false, - "input": [ - { - "type": "function_call", - "call_id": "call_example", - "name": "get_weather", - "arguments": "{\"location\":\"Shanghai\"}" - }, - {"role": "user", "content": "This cannot interrupt the tool result."}, - { - "type": "function_call_output", - "call_id": "call_example", - "output": "{\"temperature\":22}" - } - ] - })) - .await; - - assert_eq!(response.status_code(), 400); - let error = response.json::(); - assert_eq!(error.error.r#type, "invalid_request_error"); - assert!(error.error.message.contains("before any message")); -} - #[tokio::test] async fn stateless_custom_web_search_function_is_not_executed_as_a_builtin_tool() { // Install a working built-in web-search provider. If the custom function diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index 624d88d71..b3a3af671 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -1279,7 +1279,6 @@ impl CreateResponseRequest { let mut replayed_function_call_ids = HashSet::new(); let mut completed_function_call_ids = HashSet::new(); let mut pending_function_call_ids = HashSet::new(); - let mut receiving_function_outputs = false; for item in items { match item { @@ -1290,12 +1289,6 @@ impl CreateResponseRequest { ); } ResponseInputItem::FunctionCall { call_id, name, .. } => { - if receiving_function_outputs { - return Err( - "A replayed function_call block must list all function_call items before any function_call_output." - .to_string(), - ); - } if call_id.trim().is_empty() { return Err( "A replayed function_call must include call_id.".to_string() @@ -1325,15 +1318,8 @@ impl CreateResponseRequest { "function_call_output for call_id '{call_id}' must follow a matching function_call in the same stateless request" )); } - receiving_function_outputs = !pending_function_call_ids.is_empty(); } ResponseInputItem::Message { content, .. } => { - if !pending_function_call_ids.is_empty() { - return Err( - "A replayed function_call block must be followed by its matching function_call_output items before any message." - .to_string(), - ); - } if let ResponseContent::Parts(parts) = content { if parts .iter() @@ -1346,14 +1332,6 @@ impl CreateResponseRequest { } } } - ResponseInputItem::McpListTools { .. } - if !pending_function_call_ids.is_empty() => - { - return Err( - "A replayed function_call block must be followed by its matching function_call_output items before any other input item." - .to_string(), - ); - } _ => {} } } @@ -2252,7 +2230,7 @@ mod tests { } #[test] - fn stateless_function_replay_validates_contiguous_call_output_blocks() { + fn stateless_function_replay_validates_call_output_correlations() { let duplicate_call = stateless_request_with_items(vec![ replayed_function_call("call_one"), replayed_function_call("call_one"), @@ -2296,17 +2274,26 @@ mod tests { ]); assert!(parallel_calls.validate_stateless().is_ok()); - let call_after_outputs_start = stateless_request_with_items(vec![ + let interleaved_calls_and_outputs = stateless_request_with_items(vec![ replayed_function_call("call_one"), replayed_function_call("call_two"), function_call_output("call_one"), replayed_function_call("call_three"), function_call_output("call_two"), + function_call_output("call_three"), ]); - assert!(call_after_outputs_start - .validate_stateless() - .unwrap_err() - .contains("before any function_call_output")); + assert!(interleaved_calls_and_outputs.validate_stateless().is_ok()); + + let message_between_call_and_output = stateless_request_with_items(vec![ + replayed_function_call("call_one"), + ResponseInputItem::Message { + role: "user".to_string(), + content: ResponseContent::Text("continue".to_string()), + metadata: None, + }, + function_call_output("call_one"), + ]); + assert!(message_between_call_and_output.validate_stateless().is_ok()); } #[test] @@ -2369,30 +2356,6 @@ mod tests { .unwrap_err() .contains("matching function_call")); - let mut interleaved_function_result = stateless_request(); - interleaved_function_result.input = Some(ResponseInput::Items(vec![ - ResponseInputItem::FunctionCall { - type_: FunctionCallType::FunctionCall, - call_id: "call_test".to_string(), - name: "lookup".to_string(), - arguments: "{}".to_string(), - }, - ResponseInputItem::Message { - role: "user".to_string(), - content: ResponseContent::Text("interleaved input".to_string()), - metadata: None, - }, - ResponseInputItem::FunctionCallOutput { - type_: FunctionCallOutputType::FunctionCallOutput, - call_id: "call_test".to_string(), - output: "{}".to_string(), - }, - ])); - assert!(interleaved_function_result - .validate_stateless() - .unwrap_err() - .contains("before any message")); - let mut mcp_approval = stateless_request(); mcp_approval.input = Some(ResponseInput::Items(vec![ ResponseInputItem::McpApprovalResponse { From 76b75f7a2e79aade2c7ea5a46356c201a64ee7fc Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:02:13 +0800 Subject: [PATCH 16/31] fix(responses): satisfy clippy match lint --- crates/services/src/responses/models.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index b3a3af671..323e053c9 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -1319,17 +1319,16 @@ impl CreateResponseRequest { )); } } - ResponseInputItem::Message { content, .. } => { - if let ResponseContent::Parts(parts) = content { - if parts - .iter() - .any(|part| matches!(part, ResponseContentPart::InputFile { .. })) - { - return Err( - "The stateless Responses API does not support input_file." - .to_string(), - ); - } + ResponseInputItem::Message { + content: ResponseContent::Parts(parts), + .. + } => { + if parts + .iter() + .any(|part| matches!(part, ResponseContentPart::InputFile { .. })) + { + return Err("The stateless Responses API does not support input_file." + .to_string()); } } _ => {} From e246173d223afdf5d0fd79b6279676d2c2743621 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:59:49 +0800 Subject: [PATCH 17/31] refactor(responses): remove server-side agent loop --- crates/api/src/lib.rs | 97 +- crates/api/src/models.rs | 4 + crates/api/src/openapi.rs | 2 +- crates/api/src/routes/conversations.rs | 2 + crates/api/src/routes/responses.rs | 117 +- crates/api/tests/e2e_all/function_tools.rs | 115 +- crates/inference_providers/src/mock.rs | 30 +- crates/services/src/responses/models.rs | 78 +- crates/services/src/responses/service.rs | 1570 ++++------------- .../services/src/responses/service_helpers.rs | 15 +- .../services/src/responses/tools/function.rs | 1 + crates/services/src/responses/transient.rs | 23 +- 12 files changed, 732 insertions(+), 1322 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index df14d4786..b6e8c7b8e 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -436,10 +436,7 @@ pub async fn init_domain_services_with_pool( let brave_search_provider = Arc::new(services::responses::tools::brave::BraveWebSearchProvider::new()); let web_search_provider: Arc = - brave_search_provider.clone(); - let web_context_search_provider: Arc< - dyn services::responses::tools::WebContextSearchProviderTrait, - > = brave_search_provider; + brave_search_provider; // Create session repository for user service let session_repo = Arc::new(database::SessionRepository::new(database.pool().clone())) @@ -482,10 +479,7 @@ pub async fn init_domain_services_with_pool( inference_provider_pool.clone(), conversation_service.clone(), completion_service.clone(), - Some(web_search_provider.clone()), // web_search_provider - Some(web_context_search_provider), // web_context_search_provider - None, // file_search_provider - files_service.clone(), // file_service + files_service.clone(), // file_service organization_service.clone(), )); @@ -560,8 +554,11 @@ pub async fn init_domain_services_with_pool( } } -/// Initialize domain services with a custom MCP client factory (for testing) -/// This is a thin wrapper that creates the response service with an injected factory +/// Initialize domain services for legacy MCP tests. +/// +/// Remote MCP tools are no longer supported by Responses. Keep this helper's +/// signature temporarily so downstream test setup still compiles, but do not +/// inject or construct an MCP client for the Responses service. #[allow(clippy::too_many_arguments)] pub async fn init_domain_services_with_mcp_factory( database: Arc, @@ -569,7 +566,7 @@ pub async fn init_domain_services_with_mcp_factory( organization_service: Arc, inference_provider_pool: Arc, metrics_service: Arc, - mcp_client_factory: Arc, + _mcp_client_factory: Arc, ) -> DomainServices { // Get the base domain services let mut domain_services = init_domain_services_with_pool( @@ -581,33 +578,21 @@ pub async fn init_domain_services_with_mcp_factory( ) .await; - // Replace the response service with one that has the MCP factory injected + // Replace the response service while retaining the caller-provided pool. let response_repo = Arc::new(database::PgResponseRepository::new(database.pool().clone())); let response_items_repo = Arc::new(database::PgResponseItemsRepository::new( database.pool().clone(), )) as Arc; - let brave_search_provider = - Arc::new(services::responses::tools::brave::BraveWebSearchProvider::new()); - let web_search_provider: Arc = - brave_search_provider.clone(); - let web_context_search_provider: Arc< - dyn services::responses::tools::WebContextSearchProviderTrait, - > = brave_search_provider; - - let response_service = Arc::new(services::ResponseService::with_mcp_client_factory( + let response_service = Arc::new(services::ResponseService::new( response_repo, response_items_repo, inference_provider_pool, domain_services.conversation_service.clone(), domain_services.completion_service.clone(), - Some(web_search_provider), - Some(web_context_search_provider), - None, domain_services.files_service.clone(), // Reuse files_service from base organization_service, - mcp_client_factory, )); domain_services.response_service = response_service; @@ -637,8 +622,9 @@ pub async fn init_domain_services_with_pool_and_web_search_provider( .await } -/// Like `init_domain_services_with_pool_and_web_search_provider`, but also lets tests -/// inject a Responses-only context-search provider. +/// Like `init_domain_services_with_pool_and_web_search_provider`, but keeps a +/// compatibility argument for tests that previously injected a Responses-only +/// context-search provider. Responses no longer accepts that built-in tool. pub async fn init_domain_services_with_pool_and_search_providers( database: Arc, config: &ApiConfig, @@ -646,7 +632,7 @@ pub async fn init_domain_services_with_pool_and_search_providers( inference_provider_pool: Arc, metrics_service: Arc, web_search_provider: Arc, - web_context_search_provider: Option< + _web_context_search_provider: Option< Arc, >, ) -> DomainServices { @@ -671,9 +657,6 @@ pub async fn init_domain_services_with_pool_and_search_providers( inference_provider_pool, domain_services.conversation_service.clone(), domain_services.completion_service.clone(), - Some(web_search_provider.clone()), - web_context_search_provider, - None, domain_services.files_service.clone(), organization_service, )); @@ -2528,6 +2511,58 @@ mod tests { assert!(spec.servers.is_none() || spec.servers.as_ref().unwrap().is_empty()); } + #[test] + fn test_openapi_stateless_responses_request_excludes_rejected_inputs() { + let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let request_schema = &spec["paths"]["/v1/responses"]["post"]["requestBody"]["content"] + ["application/json"]["schema"]; + assert_eq!( + request_schema["$ref"], + "#/components/schemas/StatelessCreateResponseRequestSchema" + ); + + let schemas = &spec["components"]["schemas"]; + let request = &schemas["StatelessCreateResponseRequestSchema"]; + for rejected_field in ["conversation", "previous_response_id", "background"] { + assert!( + request["properties"].get(rejected_field).is_none(), + "stateless Responses schema must not advertise {rejected_field}" + ); + } + assert_eq!(request["properties"]["store"]["default"], false); + + let input_items = serde_json::to_string(&schemas["StatelessResponseInputItemSchema"]) + .expect("input item schema should serialize"); + assert!( + !input_items.contains("mcp_approval_response") + && !input_items.contains("mcp_list_tools"), + "stateless Responses schema must not advertise MCP input items" + ); + + let content_parts = serde_json::to_string(&schemas["StatelessResponseContentPartSchema"]) + .expect("content part schema should serialize"); + assert!( + !content_parts.contains("input_file"), + "stateless Responses schema must not advertise input_file" + ); + + let tools = serde_json::to_string(&schemas["StatelessResponseToolSchema"]) + .expect("tool schema should serialize"); + for rejected_tool in [ + "web_search", + "web_context_search", + "file_search", + "code_interpreter", + "computer", + "mcp", + ] { + assert!( + !tools.contains(rejected_tool), + "stateless Responses schema must not advertise {rejected_tool}" + ); + } + } + fn assert_reporting_path_security( spec: &serde_json::Value, path: &str, diff --git a/crates/api/src/models.rs b/crates/api/src/models.rs index 6f8ef84f3..fc6ab39a7 100644 --- a/crates/api/src/models.rs +++ b/crates/api/src/models.rs @@ -2313,6 +2313,10 @@ pub enum ConversationItem { name: String, /// JSON-encoded arguments arguments: String, + /// Provider-specific metadata that must be echoed unchanged when + /// replaying the function call (for example Gemini's thought signature). + #[serde(skip_serializing_if = "Option::is_none")] + thought_signature: Option, /// Status: "in_progress" when pending client execution status: String, model: String, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 80e3c5bb2..5d0982050 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -227,7 +227,7 @@ use utoipa::{Modify, OpenApi}; crate::routes::users::UpdateUserProfileRequest, crate::routes::users::UserStatusResponse, // Response models - CreateResponseRequest, ResponseObject, + crate::routes::responses::StatelessCreateResponseRequestSchema, ResponseObject, // Attestation models crate::routes::attestation::SignatureResponse, crate::routes::attestation::AttestationResponse, diff --git a/crates/api/src/routes/conversations.rs b/crates/api/src/routes/conversations.rs index 31ff254f6..82b3e6cd1 100644 --- a/crates/api/src/routes/conversations.rs +++ b/crates/api/src/routes/conversations.rs @@ -1350,6 +1350,7 @@ fn convert_output_item_to_conversation_item( call_id, name, arguments, + thought_signature, status, model, } => ConversationItem::FunctionCall { @@ -1361,6 +1362,7 @@ fn convert_output_item_to_conversation_item( call_id, name, arguments, + thought_signature, status, model, }, diff --git a/crates/api/src/routes/responses.rs b/crates/api/src/routes/responses.rs index 903281c5c..f97f35d07 100644 --- a/crates/api/src/routes/responses.rs +++ b/crates/api/src/routes/responses.rs @@ -27,6 +27,121 @@ use tracing::debug; /// prevents a completed inference response from being delivered. const RESPONSE_ATTESTATION_STORE_TIMEOUT: Duration = Duration::from_secs(5); +/// OpenAPI-only view of the stateless Responses request contract. +/// +/// The runtime request type keeps legacy variants so it can return a precise +/// `invalid_request_error` for them. The public endpoint schema should instead +/// show only the items accepted by the stateless implementation. +#[derive(serde::Deserialize, utoipa::ToSchema)] +pub struct StatelessCreateResponseRequestSchema { + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + /// Must be `false` when supplied; omitted is normalized to `false`. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(default = false, example = false)] + pub store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub service_tier: Option, +} + +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum StatelessResponseInputSchema { + Text(String), + Items(Vec), +} + +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum StatelessResponseInputItemSchema { + FunctionCall { + #[serde(rename = "type")] + type_: FunctionCallType, + call_id: String, + name: String, + arguments: String, + #[serde(skip_serializing_if = "Option::is_none")] + thought_signature: Option, + }, + FunctionCallOutput { + #[serde(rename = "type")] + type_: FunctionCallOutputType, + call_id: String, + output: String, + }, + Message { + role: String, + content: StatelessResponseContentSchema, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + }, +} + +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum StatelessResponseContentSchema { + Text(String), + Parts(Vec), +} + +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(tag = "type")] +pub enum StatelessResponseContentPartSchema { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "output_text")] + OutputText { text: String }, + #[serde(rename = "input_image")] + InputImage { + image_url: ResponseImageUrl, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} + +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(tag = "type")] +pub enum StatelessResponseToolSchema { + #[serde(rename = "function")] + Function { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + parameters: Option, + }, +} + // Helper functions for error mapping fn map_response_error_to_status(error: &ServiceResponseError) -> StatusCode { match error { @@ -319,7 +434,7 @@ pub async fn response_history_gone() -> axum::response::Response { post, path = "/v1/responses", tag = "Responses", - request_body = CreateResponseRequest, + request_body = StatelessCreateResponseRequestSchema, responses( (status = 200, description = "Response created", body = ResponseObject), (status = 400, description = "Invalid request", body = ErrorResponse), diff --git a/crates/api/tests/e2e_all/function_tools.rs b/crates/api/tests/e2e_all/function_tools.rs index 60269566d..a45596856 100644 --- a/crates/api/tests/e2e_all/function_tools.rs +++ b/crates/api/tests/e2e_all/function_tools.rs @@ -5,12 +5,7 @@ use inference_providers::{ mock::{RequestMatcher, ResponseTemplate, ToolCall}, MessageRole, }; -use services::responses::models::McpDiscoveredTool; -use services::responses::tools::{MockMcpClient, MockMcpClientFactory}; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; +use std::sync::Arc; #[tokio::test] async fn stateless_function_call_is_replayed_by_the_client_without_server_history() { @@ -28,7 +23,8 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor ResponseTemplate::new("").with_tool_calls(vec![ToolCall::new( "get_weather", r#"{"location":"Shanghai"}"#, - )]), + ) + .with_thought_signature("gemini-thought-signature")]), ) .await; mock.set_default_response(ResponseTemplate::new( @@ -71,6 +67,11 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor first["incomplete_details"]["reason"], "function_call_required" ); + assert_eq!( + mock.chat_completion_call_count(), + 1, + "a client-managed function call must not trigger a follow-up completion" + ); let function_call = first["output"] .as_array() .expect("first response has output") @@ -82,6 +83,10 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor .as_str() .expect("function call has call_id") .to_string(); + assert_eq!( + function_call["thought_signature"], + "gemini-thought-signature" + ); // The caller executes the function and sends both the returned call item // and its result in a new stateless request. No response ID is referenced. @@ -148,6 +153,10 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].id.as_deref(), Some(call_id.as_str())); assert_eq!(tool_calls[0].function.name.as_deref(), Some("get_weather")); + assert_eq!( + tool_calls[0].thought_signature.as_deref(), + Some("gemini-thought-signature") + ); let tool_result = params .messages @@ -159,6 +168,44 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor tool_result.content.as_ref(), Some(&serde_json::json!("{\"temperature_c\":22}")) ); + assert_eq!( + mock.chat_completion_call_count(), + 2, + "each client request should result in exactly one completion" + ); +} + +#[tokio::test] +async fn stateless_response_without_tools_completes_after_one_completion() { + let (server, _pool, mock, _database) = setup_test_server_with_pool().await; + let model = setup_qwen_model(&server).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + mock.set_default_response(ResponseTemplate::new("A normal answer.")) + .await; + + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model, + "input": "Say hello.", + "store": false, + "stream": false, + })) + .await; + + assert_eq!( + response.status_code(), + 200, + "response failed: {}", + response.text() + ); + let response = response.json::(); + assert_eq!(response["status"], "completed"); + assert_eq!(response["tools"], serde_json::json!([])); + assert_eq!(mock.chat_completion_call_count(), 1); } #[tokio::test] @@ -228,46 +275,9 @@ async fn stateless_custom_web_search_function_is_not_executed_as_a_builtin_tool( } #[tokio::test] -async fn stateless_custom_function_colliding_with_discovered_mcp_tool_is_rejected_before_execution() -{ - let list_tools_calls = Arc::new(AtomicUsize::new(0)); - let mcp_tool_calls = Arc::new(AtomicUsize::new(0)); - let list_tools_calls_for_factory = list_tools_calls.clone(); - let mcp_tool_calls_for_factory = mcp_tool_calls.clone(); - - let mut mock_factory = MockMcpClientFactory::new(); - mock_factory - .expect_create_client() - .withf(|url: &str, _| url == "https://example.com/mcp") - .returning(move |_, _| { - let list_tools_calls = list_tools_calls_for_factory.clone(); - let mcp_tool_calls = mcp_tool_calls_for_factory.clone(); - let mut client = MockMcpClient::new(); - - client.expect_list_tools().returning(move || { - list_tools_calls.fetch_add(1, Ordering::SeqCst); - Ok(vec![McpDiscoveredTool { - name: "get_weather".to_string(), - description: Some("Get weather for a location".to_string()), - input_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"location": {"type": "string"}} - })), - annotations: None, - }]) - }); - // The collision must be rejected before the model can cause an - // MCP call. Allow a call only so the counter produces a clear - // regression assertion rather than a mock expectation panic. - client.expect_call_tool().times(0..).returning(move |_, _| { - mcp_tool_calls.fetch_add(1, Ordering::SeqCst); - Ok("unexpected MCP execution".to_string()) - }); - - Ok(Box::new(client) as Box) - }); - - let (server, _pool, mock) = setup_test_server_with_mcp_factory(Arc::new(mock_factory)).await; +async fn stateless_mcp_tools_are_rejected_before_provider_work() { + let (server, _pool, mock, _database) = setup_test_server_with_pool().await; + let model = setup_qwen_model(&server).await; let org = setup_org_with_credits(&server, 10_000_000_000i64).await; let api_key = get_api_key_for_org(&server, org.id).await; @@ -275,7 +285,7 @@ async fn stateless_custom_function_colliding_with_discovered_mcp_tool_is_rejecte .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ - "model": "test-model", + "model": model, "input": "What is the weather?", "store": false, "stream": false, @@ -298,14 +308,9 @@ async fn stateless_custom_function_colliding_with_discovered_mcp_tool_is_rejecte assert_eq!(response.status_code(), 400, "response: {}", response.text()); let error = response.json::(); assert_eq!(error.error.r#type, "invalid_request_error"); - assert!(error - .error - .message - .contains("conflicts with a configured or discovered server-executed tool")); - assert_eq!(list_tools_calls.load(Ordering::SeqCst), 1); - assert_eq!(mcp_tool_calls.load(Ordering::SeqCst), 0); + assert!(error.error.message.contains("mcp is not supported")); assert!( mock.last_chat_params().await.is_none(), - "the request must be rejected before inference" + "MCP should be rejected before provider work" ); } diff --git a/crates/inference_providers/src/mock.rs b/crates/inference_providers/src/mock.rs index 0aaf165dd..ca694b5f3 100644 --- a/crates/inference_providers/src/mock.rs +++ b/crates/inference_providers/src/mock.rs @@ -205,6 +205,9 @@ pub struct ToolCall { pub name: String, /// JSON arguments for the tool pub arguments: String, + /// Provider metadata required when replaying the call (for example + /// Gemini's thought signature). + pub thought_signature: Option, } impl ToolCall { @@ -213,8 +216,15 @@ impl ToolCall { Self { name: name.into(), arguments: arguments.into(), + thought_signature: None, } } + + /// Attach provider metadata that must survive a tool-call round trip. + pub fn with_thought_signature(mut self, thought_signature: impl Into) -> Self { + self.thought_signature = Some(thought_signature.into()); + self + } } /// Template for generating responses @@ -349,7 +359,7 @@ impl ResponseTemplate { name: Some(tc.name.clone()), arguments: Some(tc.arguments.clone()), }, - thought_signature: None, + thought_signature: tc.thought_signature.clone(), }) .collect() }); @@ -538,7 +548,7 @@ impl ResponseTemplate { name: Some(tc.name.clone()), arguments: None, }), - thought_signature: None, + thought_signature: tc.thought_signature.clone(), }]), reasoning_content: None, reasoning: None, @@ -686,6 +696,9 @@ pub struct MockProvider { config: Arc>, /// Last chat completion params received (for test assertions) last_chat_params: Arc>>, + /// Number of chat-completion requests received, across streaming and + /// non-streaming calls. Useful for asserting one-shot API behavior. + chat_completion_call_count: Arc, /// When true, get_attestation_report returns an error (simulates blocked/broken backend) fail_attestation: Arc, /// Trust tier reported by [`InferenceProvider::tier`]; defaults to @@ -732,6 +745,7 @@ impl MockProvider { audio_transcription_error_override: None, })), last_chat_params: Arc::new(Mutex::new(None)), + chat_completion_call_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)), fail_attestation: Arc::new(std::sync::atomic::AtomicBool::new(false)), tier: crate::ProviderTier::NonAttested, provider_source: crate::ProviderSource::External, @@ -757,6 +771,7 @@ impl MockProvider { audio_transcription_error_override: None, })), last_chat_params: Arc::new(Mutex::new(None)), + chat_completion_call_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)), fail_attestation: Arc::new(std::sync::atomic::AtomicBool::new(false)), tier: crate::ProviderTier::NonAttested, provider_source: crate::ProviderSource::External, @@ -780,6 +795,7 @@ impl MockProvider { audio_transcription_error_override: None, })), last_chat_params: Arc::new(Mutex::new(None)), + chat_completion_call_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)), fail_attestation: Arc::new(std::sync::atomic::AtomicBool::new(false)), tier: crate::ProviderTier::NonAttested, provider_source: crate::ProviderSource::External, @@ -827,6 +843,12 @@ impl MockProvider { self.last_chat_params.lock().await.clone() } + /// Number of calls made to `chat_completion` or `chat_completion_stream`. + pub fn chat_completion_call_count(&self) -> usize { + self.chat_completion_call_count + .load(std::sync::atomic::Ordering::SeqCst) + } + /// Chat ids for which [`crate::InferenceProvider::unpin_chat_connection`] /// was called, in call order. Used by lifecycle tests to assert the /// signature-fetch routing pin was released. @@ -1049,6 +1071,8 @@ impl crate::InferenceProvider for MockProvider { params: ChatCompletionParams, request_hash: String, ) -> Result { + self.chat_completion_call_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); *self.last_chat_params.lock().await = Some(params.clone()); // Check for invalid model @@ -1176,6 +1200,8 @@ impl crate::InferenceProvider for MockProvider { params: ChatCompletionParams, request_hash: String, ) -> Result { + self.chat_completion_call_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); *self.last_chat_params.lock().await = Some(params.clone()); // Check for invalid model diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index 323e053c9..99b0968f1 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -122,6 +122,10 @@ pub enum ResponseInputItem { name: String, /// JSON-encoded arguments returned by the model. arguments: String, + /// Provider-specific metadata (for example Gemini's thought signature) + /// that must be echoed unchanged when this call is replayed. + #[serde(skip_serializing_if = "Option::is_none")] + thought_signature: Option, }, /// Output from a client-executed function call FunctionCallOutput { @@ -655,6 +659,10 @@ pub enum ResponseOutputItem { name: String, /// JSON-encoded arguments arguments: String, + /// Provider-specific metadata that clients must echo unchanged when + /// replaying the function call (for example Gemini's thought signature). + #[serde(skip_serializing_if = "Option::is_none")] + thought_signature: Option, /// Status: "in_progress" when pending client execution status: String, model: String, @@ -1282,9 +1290,10 @@ impl CreateResponseRequest { for item in items { match item { - ResponseInputItem::McpApprovalResponse { .. } => { + ResponseInputItem::McpApprovalResponse { .. } + | ResponseInputItem::McpListTools { .. } => { return Err( - "The stateless Responses API does not support MCP approval continuation." + "The stateless Responses API does not support mcp input items." .to_string(), ); } @@ -1344,22 +1353,22 @@ impl CreateResponseRequest { } if let Some(tools) = &self.tools { - let mut custom_function_names = HashSet::new(); - let mut configured_builtin_names = HashSet::new(); - for tool in tools { match tool { - ResponseTool::Function { name, .. } => { - custom_function_names.insert(name.as_str()); - } + ResponseTool::Function { .. } => {} ResponseTool::WebSearch { .. } => { - configured_builtin_names.insert("web_search"); + return Err( + "The stateless Responses API only supports custom function tools; web_search is not supported." + .to_string(), + ); } ResponseTool::WebContextSearch {} => { - configured_builtin_names.insert("web_context_search"); + return Err( + "The stateless Responses API only supports custom function tools; web_context_search is not supported." + .to_string(), + ); } ResponseTool::FileSearch { .. } => { - configured_builtin_names.insert("file_search"); return Err( "The stateless Responses API does not support file_search.".to_string() ); @@ -1376,30 +1385,14 @@ impl CreateResponseRequest { .to_string(), ); } - ResponseTool::Mcp { - require_approval, .. - } if !matches!( - require_approval, - McpApprovalRequirement::Simple(McpApprovalMode::Never) - ) => - { + ResponseTool::Mcp { .. } => { return Err( - "The stateless Responses API does not support MCP tools that require approval." + "The stateless Responses API only supports custom function tools; mcp is not supported." .to_string(), ); } - _ => {} } } - - if let Some(name) = custom_function_names - .iter() - .find(|name| configured_builtin_names.contains(*name)) - { - return Err(format!( - "Custom function '{name}' conflicts with a configured built-in tool of the same name." - )); - } } Ok(()) @@ -1495,6 +1488,7 @@ mod tests { call_id: call_id.to_string(), name: "lookup".to_string(), arguments: "{}".to_string(), + thought_signature: None, } } @@ -2366,7 +2360,25 @@ mod tests { assert!(mcp_approval .validate_stateless() .unwrap_err() - .contains("MCP approval continuation")); + .contains("mcp input items")); + + let mut web_search = stateless_request(); + web_search.tools = Some(vec![ResponseTool::WebSearch { + filters: None, + search_context_size: None, + user_location: None, + }]); + assert!(web_search + .validate_stateless() + .unwrap_err() + .contains("web_search is not supported")); + + let mut web_context_search = stateless_request(); + web_context_search.tools = Some(vec![ResponseTool::WebContextSearch {}]); + assert!(web_context_search + .validate_stateless() + .unwrap_err() + .contains("web_context_search is not supported")); let mut file_search = stateless_request(); file_search.tools = Some(vec![ResponseTool::FileSearch {}]); @@ -2401,11 +2413,11 @@ mod tests { assert!(mcp_tool .validate_stateless() .unwrap_err() - .contains("require approval")); + .contains("mcp is not supported")); } #[test] - fn stateless_requests_reject_custom_function_name_colliding_with_builtin_tool() { + fn stateless_requests_reject_builtin_tools_even_when_a_function_has_the_same_name() { let mut request = stateless_request(); request.tools = Some(vec![ ResponseTool::Function { @@ -2423,6 +2435,6 @@ mod tests { assert!(request .validate_stateless() .unwrap_err() - .contains("conflicts with a configured built-in tool")); + .contains("web_search is not supported")); } } diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index bd0c3f8d7..251ad1430 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -13,18 +13,6 @@ use crate::inference_provider_pool::InferenceProviderPool; use crate::responses::tools; use crate::responses::{citation_tracker, errors, models, ports, transient}; -use tools::{ERROR_TOOL_TYPE, MAX_CONSECUTIVE_TOOL_FAILURES}; - -/// Result of the agent loop execution -enum AgentLoopResult { - /// Agent loop completed normally - Completed, - /// Agent loop paused due to MCP approval required - ApprovalRequired, - /// Agent loop paused due to external function calls requiring client execution - FunctionCallsRequired, -} - /// Context for processing a response stream struct ProcessStreamContext { request: models::CreateResponseRequest, @@ -45,10 +33,6 @@ struct ProcessStreamContext { file_service: Arc, organization_service: Arc, source_registry: Option, - web_search_failure_count: u32, - mcp_executor: Option>, - mcp_client_factory: Option>, - tool_registry: tools::ToolRegistry, } pub struct ResponseServiceImpl { @@ -57,13 +41,8 @@ pub struct ResponseServiceImpl { pub inference_provider_pool: Arc, pub conversation_service: Arc, pub completion_service: Arc, - pub web_search_provider: Option>, - pub web_context_search_provider: Option>, - pub file_search_provider: Option>, pub file_service: Arc, pub organization_service: Arc, - /// Optional MCP client factory for testing (if None, uses RealMcpClientFactory) - pub mcp_client_factory: Option>, } /// Tag transition states for reasoning content @@ -75,48 +54,14 @@ enum TagTransition { } impl ResponseServiceImpl { - #[allow(clippy::too_many_arguments)] pub fn new( response_repository: Arc, response_items_repository: Arc, inference_provider_pool: Arc, conversation_service: Arc, completion_service: Arc, - web_search_provider: Option>, - web_context_search_provider: Option>, - file_search_provider: Option>, - file_service: Arc, - organization_service: Arc, - ) -> Self { - Self { - response_repository, - response_items_repository, - inference_provider_pool, - conversation_service, - completion_service, - web_search_provider, - web_context_search_provider, - file_search_provider, - file_service, - organization_service, - mcp_client_factory: None, - } - } - - /// Create a new ResponseServiceImpl with a custom MCP client factory (for testing) - #[allow(clippy::too_many_arguments)] - pub fn with_mcp_client_factory( - response_repository: Arc, - response_items_repository: Arc, - inference_provider_pool: Arc, - conversation_service: Arc, - completion_service: Arc, - web_search_provider: Option>, - web_context_search_provider: Option>, - file_search_provider: Option>, file_service: Arc, organization_service: Arc, - mcp_client_factory: Arc, ) -> Self { Self { response_repository, @@ -124,12 +69,8 @@ impl ResponseServiceImpl { inference_provider_pool, conversation_service, completion_service, - web_search_provider, - web_context_search_provider, - file_search_provider, file_service, organization_service, - mcp_client_factory: Some(mcp_client_factory), } } } @@ -167,68 +108,25 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { request.store = Some(false); request.background = Some(false); - // Only install server-side executors for built-in tools explicitly - // requested by this call. In particular, a client-defined function - // named `web_search` must remain client-managed rather than being - // claimed by the built-in executor. - let has_web_search = request.tools.as_ref().is_some_and(|tools| { - tools - .iter() - .any(|tool| matches!(tool, models::ResponseTool::WebSearch { .. })) - }); - let has_web_context_search = request.tools.as_ref().is_some_and(|tools| { - tools - .iter() - .any(|tool| matches!(tool, models::ResponseTool::WebContextSearch {})) - }); - let has_file_search = request.tools.as_ref().is_some_and(|tools| { - tools - .iter() - .any(|tool| matches!(tool, models::ResponseTool::FileSearch {})) - }); - // Create a channel for streaming events let (mut tx, rx) = mpsc::unbounded::(); // Each request gets its own in-memory repositories. This preserves the - // existing event and tool execution flow without creating, reading, or - // updating rows in `responses` or `response_items`. + // Responses event shape without creating, reading, or updating rows in + // `responses` or `response_items`. let (response_repository, response_items_repository) = transient::repositories(); // Clone necessary references for the async task let completion_service = self.completion_service.clone(); let conversation_service = self.conversation_service.clone(); - let web_search_provider = self.web_search_provider.clone(); - let web_context_search_provider = self.web_context_search_provider.clone(); - let file_search_provider = self.file_search_provider.clone(); let file_service = self.file_service.clone(); let organization_service = self.organization_service.clone(); - let mcp_client_factory = self.mcp_client_factory.clone(); let signing_algo_clone = signing_algo.clone(); let client_pub_key_clone = client_pub_key.clone(); let model_pub_key_clone = model_pub_key.clone(); let encryption_version_clone = encryption_version.clone(); tokio::spawn(async move { - let mut tool_registry = tools::ToolRegistry::new(); - if has_web_search { - if let Some(provider) = web_search_provider { - tool_registry.register(Arc::new(tools::WebSearchToolExecutor::new(provider))); - } - } - if has_web_context_search { - if let Some(provider) = web_context_search_provider { - tool_registry - .register(Arc::new(tools::WebContextSearchToolExecutor::new(provider))); - } - } - if has_file_search { - if let Some(provider) = file_search_provider { - tool_registry.register(Arc::new(tools::FileSearchToolExecutor::new(provider))); - } - } - // Note: MCP executor is added later after connecting to servers - // Shared tracker so the outer error handler can read accumulated // usage after `ctx` is dropped on Err from process_response_stream. let usage_tracker = crate::responses::service_helpers::UsageTracker::new(); @@ -252,10 +150,6 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { file_service, organization_service, source_registry: None, - web_search_failure_count: 0, - mcp_executor: None, - mcp_client_factory, - tool_registry, }; if let Err(e) = @@ -317,58 +211,6 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { } impl ResponseServiceImpl { - /// Reject custom function names that conflict with a tool Cloud executes. - /// - /// Fixed built-ins are known during request validation; discovered MCP - /// names must be checked here, after MCP setup and before inference or - /// executor registration. - fn validate_custom_function_tool_name_collisions( - request: &models::CreateResponseRequest, - mcp_tool_definitions: &[inference_providers::ToolDefinition], - ) -> Result<(), errors::ResponseError> { - let mut server_executed_tool_names = HashSet::new(); - - if let Some(configured_tools) = &request.tools { - for tool in configured_tools { - match tool { - models::ResponseTool::WebSearch { .. } => { - server_executed_tool_names.insert(tools::WEB_SEARCH_TOOL_NAME.to_string()); - } - models::ResponseTool::WebContextSearch {} => { - server_executed_tool_names - .insert(tools::WEB_CONTEXT_SEARCH_TOOL_NAME.to_string()); - } - models::ResponseTool::FileSearch {} => { - server_executed_tool_names.insert(tools::FILE_SEARCH_TOOL_NAME.to_string()); - } - _ => {} - } - } - } - - server_executed_tool_names.extend( - mcp_tool_definitions - .iter() - .map(|definition| definition.function.name.clone()), - ); - - let Some(configured_tools) = &request.tools else { - return Ok(()); - }; - - for tool in configured_tools { - if let models::ResponseTool::Function { name, .. } = tool { - if server_executed_tool_names.contains(name) { - return Err(errors::ResponseError::InvalidParams(format!( - "Custom function '{name}' conflicts with a configured or discovered server-executed tool of the same name." - ))); - } - } - } - - Ok(()) - } - /// Parse file ID from string (handles prefix) fn parse_file_id(file_id: &str) -> Result { let id_str = file_id @@ -848,15 +690,12 @@ impl ResponseServiceImpl { .await?; } - // Convert accumulated tool calls to detected tool calls - let available_tool_names = tools::get_tool_names(&process_context.request); - let function_tool_names = tools::get_function_tool_names(&process_context.request); - let tool_calls_detected = tools::convert_tool_calls( - tool_call_accumulator, - &process_context.request.model, - &available_tool_names, - &function_tool_names, - ); + // Stateless Responses only returns client-managed custom functions. + // Do not route their raw arguments through the legacy builtin parser: + // that parser can repair and reserialize JSON for server-executed + // search tools, which would corrupt a client replay. + let tool_calls_detected = + Self::convert_client_function_calls(tool_call_accumulator, &process_context.request)?; Ok(crate::responses::service_helpers::ProcessStreamResult { text: current_text, @@ -1008,47 +847,7 @@ impl ResponseServiceImpl { let workspace_id_domain = crate::workspace::WorkspaceId(context.workspace_id); - // Verify previous_response_id ownership before loading any history or - // creating the response. Unknown and foreign response IDs get the same - // non-enumerating 404-style error; without this check a foreign ID - // would be persisted as this response's parent edge. - if let Some(prev_response_id) = &context.request.previous_response_id { - let uuid_str = prev_response_id - .strip_prefix(crate::id_prefixes::PREFIX_RESP) - .unwrap_or(prev_response_id); - let prev_uuid = Uuid::parse_str(uuid_str).map_err(|e| { - errors::ResponseError::InvalidParams(format!("invalid previous_response_id: {e}")) - })?; - - let prev_response = context - .response_repository - .get_by_id(models::ResponseId(prev_uuid), workspace_id_domain.clone()) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!( - "Failed to verify previous response ownership: {e}" - )) - })?; - - if prev_response.is_none() { - return Err(errors::ResponseError::PreviousResponseNotFound); - } - } - - // Validate MCP approval-request references before creating the - // response row. setup_mcp() re-resolves them later (workspace-scoped), - // but that runs after the response has been persisted and - // response.created has been emitted — failing only there would leave - // an orphaned in-progress response. Unknown and foreign IDs produce - // the same not-found error here as in the approval processing itself. - Self::validate_mcp_approval_references( - &context.request, - &context.response_items_repository, - workspace_id_domain.clone(), - ) - .await?; - - let mut messages = Self::load_conversation_context( + let messages = Self::load_conversation_context( &context.request, &context.conversation_service, &context.response_items_repository, @@ -1130,55 +929,9 @@ impl ResponseServiceImpl { .emit_in_progress(&mut ctx, initial_response.clone()) .await?; - // Spawn background task to generate conversation title if needed - let title_task_handle = Self::maybe_generate_conversation_title( - conversation_id, - &context.request, - context.user_id.clone(), - context.api_key_id.clone(), - context.request_id, - context.organization_id, - context.workspace_id, - context.conversation_service.clone(), - context.completion_service.clone(), - emitter.tx.clone(), - context.signing_algo.clone(), - context.client_pub_key.clone(), - ); - - let mut tools = tools::prepare_tools(&context.request); + let tools = tools::prepare_tools(&context.request); let tool_choice = tools::prepare_tool_choice(&context.request); - // Set up MCP: connect to servers, discover tools, and process approvals - if let Some(mcp_setup) = tools::setup_mcp( - &context.request, - context.mcp_client_factory.as_ref(), - &context.response_items_repository, - workspace_id_domain.clone(), - &mut ctx, - &mut emitter, - ) - .await? - { - // MCP tool names are only known after discovery. Reject a custom - // function that would otherwise be claimed by the MCP executor, - // which is registered before the custom-function executor. - Self::validate_custom_function_tool_name_collisions( - &context.request, - &mcp_setup.tool_definitions, - )?; - tools.extend(mcp_setup.tool_definitions); - context.tool_registry.register(mcp_setup.executor.clone()); - context.mcp_executor = Some(mcp_setup.executor); - messages.extend(mcp_setup.approval_messages); - } - - // Set up Function tools: register executor - let function_executor = tools::FunctionToolExecutor::new(&context.request); - if !function_executor.is_empty() { - context.tool_registry.register(Arc::new(function_executor)); - } - // Check if this is an image model and handle it specially if let Ok(Some(model)) = context .completion_service @@ -1257,46 +1010,54 @@ impl ResponseServiceImpl { } } - let max_iterations = 10; // Prevent infinite loops - let mut iteration = 0; - let mut final_response_text = String::new(); + // Responses is a stateless compatibility layer over exactly one Chat + // Completions request. Client-defined functions are returned to the + // caller; Cloud never executes them or starts another completion. + let one_shot_result: Result<(String, bool), errors::ResponseError> = async { + let stream_result = Self::run_completion_once( + &mut ctx, + &mut emitter, + &messages, + &context, + &tools, + &tool_choice, + ) + .await?; - // Run the agent loop to process completion and tool calls - // Capture errors but continue to save partial data if client disconnected - let agent_loop_result = Self::run_agent_loop( - &mut ctx, - &mut emitter, - &mut messages, - &mut final_response_text, - &mut context, - &tools, - &tool_choice, - max_iterations, - &mut iteration, - ) + if stream_result.stream_error { + return Err(stream_result + .stream_error_cause + .unwrap_or(errors::ResponseError::StreamInterrupted)); + } + + // `process_completion_stream` emits any text item. Preserve the + // previous output-index transition before adding function calls. + if !stream_result.text.is_empty() { + ctx.next_output_index(); + } + + let function_calls_required = Self::emit_client_function_calls( + &mut ctx, + &mut emitter, + &context.response_items_repository, + stream_result.tool_calls, + ) + .await?; + + Ok((stream_result.text, function_calls_required)) + } .await; - // Determine final response status based on agent loop result - let (final_status, incomplete_details) = match &agent_loop_result { - Ok(AgentLoopResult::Completed) => (models::ResponseStatus::Completed, None), - Ok(AgentLoopResult::ApprovalRequired) => ( - models::ResponseStatus::Incomplete, - Some(models::ResponseIncompleteDetails { - reason: "mcp_approval_required".to_string(), - }), - ), - Ok(AgentLoopResult::FunctionCallsRequired) => ( + let (final_response_text, final_status, incomplete_details) = match &one_shot_result { + Ok((text, true)) => ( + text.clone(), models::ResponseStatus::Incomplete, Some(models::ResponseIncompleteDetails { reason: "function_call_required".to_string(), }), ), - Err(errors::ResponseError::Completion(_)) => (models::ResponseStatus::Failed, None), - Err(ref e) => { - // Log error but continue - we want to save partial response even on disconnect - tracing::warn!("Agent loop error (may be client disconnect): {:?}", e); - (models::ResponseStatus::Completed, None) - } + Ok((text, false)) => (text.clone(), models::ResponseStatus::Completed, None), + Err(_) => (String::new(), models::ResponseStatus::Failed, None), }; // Build final response @@ -1313,15 +1074,7 @@ impl ResponseServiceImpl { errors::ResponseError::InternalError(format!("Failed to load response items: {e}")) })?; - let mut output_items = Self::select_output_items(response_items, &input_item_ids); - - // Prepend MCP list tools items (emitted but not stored in DB) - if let Some(ref mcp_executor) = context.mcp_executor { - let mcp_items = mcp_executor.get_mcp_list_tools_items().to_vec(); - output_items.splice(0..0, mcp_items); - } - - final_response.output = output_items; + final_response.output = Self::select_output_items(response_items, &input_item_ids); // Set usage from accumulated token counts final_response.usage = models::Usage::new_with_reasoning_and_cache( @@ -1343,83 +1096,65 @@ impl ResponseServiceImpl { errors::ResponseError::InternalError(format!("Failed to serialize usage: {e}")) })?; - // Initial failure: agent loop failed with no output/usage → create failed item, update status, return Err (client gets response.failed). - // Otherwise (Ok or Err with partial output): update DB with final response, then emit response.completed. - match agent_loop_result { - Err(e) - if final_response.output.is_empty() && final_response.usage.total_tokens == 0 => - { + // On a one-shot completion failure, emit response.failed rather than + // trying to repair the model output with another inference request. + // Keep any partial output already emitted by the stream, but mark the + // response as failed in the request-scoped repository. + match one_shot_result { + Err(e) => { // Include error message in content so users can understand why it failed - let error_message = e.to_string(); - let failed_item = models::ResponseOutputItem::Message { - id: format!("msg_{}", Uuid::new_v4().simple()), - response_id: ctx.response_id_str.clone(), - previous_response_id: ctx.previous_response_id.clone(), - next_response_ids: vec![], - created_at: ctx.created_at, - status: models::ResponseItemStatus::Failed, - role: "assistant".to_string(), - content: vec![models::ResponseContentItem::OutputText { - text: error_message, - annotations: vec![], - logprobs: vec![], - }], - model: ctx.model.clone(), - metadata: None, - }; - if let Err(create_err) = context - .response_items_repository - .create( - ctx.response_id.clone(), - ctx.api_key_id, - ctx.conversation_id, - failed_item, - ) - .await - { - tracing::warn!("Failed to store failed response item: {}", create_err); - } - if let Err(update_err) = context - .response_repository - .update( - ctx.response_id.clone(), - workspace_id_domain.clone(), - None, - models::ResponseStatus::Failed, - None, - ) - .await - { - tracing::warn!("Failed to update response status to failed: {}", update_err); + if final_response.output.is_empty() { + let failed_item = models::ResponseOutputItem::Message { + id: format!("msg_{}", Uuid::new_v4().simple()), + response_id: ctx.response_id_str.clone(), + previous_response_id: ctx.previous_response_id.clone(), + next_response_ids: vec![], + created_at: ctx.created_at, + status: models::ResponseItemStatus::Failed, + role: "assistant".to_string(), + content: vec![models::ResponseContentItem::OutputText { + text: e.to_string(), + annotations: vec![], + logprobs: vec![], + }], + model: ctx.model.clone(), + metadata: None, + }; + if let Err(create_err) = context + .response_items_repository + .create( + ctx.response_id.clone(), + ctx.api_key_id, + ctx.conversation_id, + failed_item, + ) + .await + { + tracing::warn!("Failed to store failed response item: {}", create_err); + } } - return Err(e); - } - Err(e @ errors::ResponseError::Completion(_)) => { - if let Err(update_err) = context + if let Err(e) = context .response_repository .update( ctx.response_id.clone(), workspace_id_domain.clone(), - Some(final_response_text.clone()), + Some(final_response_text), models::ResponseStatus::Failed, Some(usage_json), ) .await { - tracing::warn!( - "Failed to update partial response status to failed: {}", - update_err - ); + tracing::warn!("Failed to update response with usage: {}", e); } return Err(e); } - _ => { + Ok(_) => { if let Err(e) = context .response_repository .update( ctx.response_id.clone(), workspace_id_domain.clone(), - Some(final_response_text.clone()), + Some(final_response_text), final_response.status.clone(), Some(usage_json), ) @@ -1430,25 +1165,6 @@ impl ResponseServiceImpl { } } - // Wait for title generation with a timeout (2 seconds) - // This ensures the title event is sent before response.completed - if let Some(handle) = title_task_handle { - match tokio::time::timeout(std::time::Duration::from_secs(2), handle).await { - Ok(Ok(Ok(()))) => { - tracing::debug!("Title generation completed before response"); - } - Ok(Ok(Err(e))) => { - tracing::warn!("Title generation failed: {:?}", e); - } - Ok(Err(e)) => { - tracing::warn!("Title generation task panicked: {:?}", e); - } - Err(_) => { - tracing::debug!("Title generation timed out, continuing with response"); - } - } - } - // Event: response.completed emitter.emit_completed(&mut ctx, final_response).await?; @@ -1456,537 +1172,222 @@ impl ResponseServiceImpl { Ok(()) } - /// Run the agent loop - repeatedly call completion API and execute tool calls + /// Execute exactly one Chat Completions request for a Responses call. #[allow(clippy::too_many_arguments)] - async fn run_agent_loop( + async fn run_completion_once( ctx: &mut crate::responses::service_helpers::ResponseStreamContext, emitter: &mut crate::responses::service_helpers::EventEmitter, - messages: &mut Vec, - final_response_text: &mut String, - process_context: &mut ProcessStreamContext, + messages: &[crate::completions::ports::CompletionMessage], + process_context: &ProcessStreamContext, tools: &[inference_providers::ToolDefinition], tool_choice: &Option, - max_iterations: usize, - iteration: &mut usize, - ) -> Result { - use crate::completions::ports::{CompletionMessage, CompletionRequest}; - - // Track consecutive error tool calls to detect excessive retry loops - let mut consecutive_error_count = 0; - - loop { - *iteration += 1; - if *iteration > max_iterations { - tracing::warn!("Max iterations reached in agent loop"); - break; - } - - tracing::debug!("Agent loop iteration {}", iteration); - - // Prepare extra params with tools and encryption headers - let mut extra = std::collections::HashMap::new(); - if !tools.is_empty() { - extra.insert("tools".to_string(), serde_json::to_value(tools).unwrap()); - } - if let Some(tc) = tool_choice { - extra.insert("tool_choice".to_string(), serde_json::to_value(tc).unwrap()); - } - - // Add encryption headers to extra for passing to completion service - if let Some(ref signing_algo) = process_context.signing_algo { - extra.insert( - encryption_headers::SIGNING_ALGO.to_string(), - serde_json::Value::String(signing_algo.clone()), - ); - } - if let Some(ref client_pub_key) = process_context.client_pub_key { - extra.insert( - encryption_headers::CLIENT_PUB_KEY.to_string(), - serde_json::Value::String(client_pub_key.clone()), - ); - } - if let Some(ref model_pub_key) = process_context.model_pub_key { - extra.insert( - encryption_headers::MODEL_PUB_KEY.to_string(), - serde_json::Value::String(model_pub_key.clone()), - ); - } - if let Some(ref encryption_version) = process_context.encryption_version { - extra.insert( - encryption_headers::ENCRYPTION_VERSION.to_string(), - serde_json::Value::String(encryption_version.clone()), - ); - } - - // Create completion request (names not included - tracked via database analytics) - let completion_request = CompletionRequest { - request_id: process_context.request_id, - model: process_context.request.model.clone(), - messages: messages.clone(), - max_tokens: process_context.request.max_output_tokens, - temperature: process_context.request.temperature, - top_p: process_context.request.top_p, - stop: None, - stream: Some(true), - user_id: process_context.user_id.clone(), - api_key_id: process_context.api_key_id.to_string(), - organization_id: process_context.organization_id, - workspace_id: process_context.workspace_id, - metadata: process_context.request.metadata.clone(), - store: process_context.request.store, - body_hash: process_context.body_hash.to_string(), - // The response ID is an in-memory event identifier only. Do - // not link usage records to a database response row. - response_id: None, - skip_provider_chat_signature: false, - original_request: None, - n: None, - service_tier: Some(inference_providers::ChatServiceTier::Default), - extra, - }; - - // Get completion stream - let completion_result = process_context - .completion_service - .create_chat_completion_stream(completion_request) - .await - .map_err(errors::ResponseError::from)?; - - let mut completion_stream = completion_result; - - // Process the completion stream and extract text + tool calls - let stream_result = Self::process_completion_stream( - &mut completion_stream, - emitter, - ctx, - &process_context.response_items_repository, - process_context, - ) - .await?; - - // Update response state before handling stream errors so partial upstream output - // is persisted even when the provider later returns a typed error. - if !stream_result.text.is_empty() { - final_response_text.push_str(&stream_result.text); - } - - // If stream errored (client disconnect, network error, etc.), stop the agent loop - if stream_result.stream_error { - tracing::info!("Stream error detected, stopping agent loop"); - return Err(stream_result - .stream_error_cause - .unwrap_or(errors::ResponseError::StreamInterrupted)); - } - - // Check if we're done (no tool calls) - if stream_result.tool_calls.is_empty() { - // No tool calls - add assistant message with just text (if any) - if !stream_result.text.is_empty() { - messages.push(CompletionMessage { - role: "assistant".to_string(), - content: serde_json::Value::String(stream_result.text.clone()), - tool_call_id: None, - tool_calls: None, - }); - ctx.next_output_index(); - } - tracing::debug!("No tool calls detected, ending agent loop"); - break; - } - - // Tool calls present - add assistant message with tool_calls - // This is REQUIRED by all providers (OpenAI, Anthropic, Gemini): - // "messages with role 'tool' must be a response to a preceding message with 'tool_calls'" - let completion_tool_calls: Vec = - stream_result - .tool_calls - .iter() - .map(|tc| { - let id = tc - .id - .clone() - .expect("ToolCallInfo.id always set by convert_tool_calls"); - crate::completions::ports::CompletionToolCall { - id, - name: tc.tool_type.clone(), - arguments: tc - .params - .as_ref() - .map(|p| p.to_string()) - .unwrap_or_else(|| "{}".to_string()), - thought_signature: tc.thought_signature.clone(), - } - }) - .collect(); - - // Defensive: only set tool_calls if non-empty (some providers reject empty arrays) - let tool_calls = if completion_tool_calls.is_empty() { - None - } else { - Some(completion_tool_calls.clone()) - }; - - messages.push(CompletionMessage { - role: "assistant".to_string(), - content: serde_json::Value::String(stream_result.text.clone()), - tool_call_id: None, - tool_calls, - }); - if !stream_result.text.is_empty() { - ctx.next_output_index(); - } - - let has_errors = stream_result - .tool_calls - .iter() - .any(|tc| tc.tool_type == ERROR_TOOL_TYPE); - if has_errors { - consecutive_error_count += 1; - if consecutive_error_count >= MAX_CONSECUTIVE_TOOL_FAILURES { - tracing::error!( - "Agent loop: {} consecutive iterations with tool call errors, stopping to prevent infinite retry", - consecutive_error_count - ); - return Err(errors::ResponseError::InternalError( - format!("Tool calls failed {} consecutive iterations due to malformed arguments from model", MAX_CONSECUTIVE_TOOL_FAILURES), - )); - } - } else { - consecutive_error_count = 0; - } - - tracing::debug!("Executing {} tool calls", stream_result.tool_calls.len()); - - // Execute each tool call, collecting any deferred instructions - // Also track pending function calls for batching - let mut deferred_instructions: Vec = Vec::new(); - let mut pending_function_calls: Vec = Vec::new(); - - for tool_call in stream_result.tool_calls { - match Self::execute_and_emit_tool_call( - ctx, - emitter, - &tool_call, - messages, - process_context, - &mut deferred_instructions, - ) - .await? - { - tools::ToolExecutionResult::Success => { - // Continue processing tool calls - } - tools::ToolExecutionResult::ApprovalRequired => { - // MCP tool requires approval - flush any deferred instructions before pausing - if !deferred_instructions.is_empty() { - messages.push(CompletionMessage { - role: "system".to_string(), - content: serde_json::Value::String( - std::mem::take(&mut deferred_instructions).join("\n\n"), - ), - tool_call_id: None, - tool_calls: None, - }); - } - return Ok(AgentLoopResult::ApprovalRequired); - } - tools::ToolExecutionResult::FunctionCallPending(info) => { - // Collect pending function calls for batching - pending_function_calls.push(info); - } - } - } - - // If there are pending function calls, return them all at once - // This supports parallel tool calls - we batch all function calls before pausing - if !pending_function_calls.is_empty() { - if !deferred_instructions.is_empty() { - messages.push(CompletionMessage { - role: "system".to_string(), - content: serde_json::Value::String( - std::mem::take(&mut deferred_instructions).join("\n\n"), - ), - tool_call_id: None, - tool_calls: None, - }); - } - return Ok(AgentLoopResult::FunctionCallsRequired); - } + ) -> Result { + use crate::completions::ports::CompletionRequest; - // Add deferred instructions AFTER all tool results (combined into one system message) - // This ensures tool results are consecutive (required by OpenAI/Anthropic/Gemini) - if !deferred_instructions.is_empty() { - messages.push(CompletionMessage { - role: "system".to_string(), - content: serde_json::Value::String(deferred_instructions.join("\n\n")), - tool_call_id: None, - tool_calls: None, - }); - } + let mut extra = std::collections::HashMap::new(); + if !tools.is_empty() { + let tools = serde_json::to_value(tools).map_err(|e| { + errors::ResponseError::InternalError(format!( + "Failed to serialize custom function definitions: {e}" + )) + })?; + extra.insert("tools".to_string(), tools); } - - Ok(AgentLoopResult::Completed) - } - - /// Execute a tool call and emit appropriate events. - /// - /// Returns `Ok(ToolExecutionResult::Success)` if the tool executed normally, - /// or `Ok(ToolExecutionResult::ApprovalRequired)` if the tool requires user approval. - /// - /// Any instructions (e.g., citation instructions from web search) are collected into - /// `deferred_instructions` to be added after all tool results. This ensures tool results - /// are consecutive (required by OpenAI/Anthropic/Gemini for parallel tool calls). - async fn execute_and_emit_tool_call( - ctx: &mut crate::responses::service_helpers::ResponseStreamContext, - emitter: &mut crate::responses::service_helpers::EventEmitter, - tool_call: &crate::responses::service_helpers::ToolCallInfo, - messages: &mut Vec, - process_context: &mut ProcessStreamContext, - deferred_instructions: &mut Vec, - ) -> Result { - use crate::completions::ports::CompletionMessage; - - // Use the tool call ID (always set by convert_tool_calls; required for matching tool results to tool calls) - let tool_call_id = tool_call - .id - .clone() - .expect("ToolCallInfo.id always set by convert_tool_calls"); - - // Handle error tool calls (malformed tool calls detected during parsing) - if tool_call.tool_type == ERROR_TOOL_TYPE { - // For error tool calls, return the error message as the tool result - // This allows the LLM to see what went wrong and retry - // Note: tool_call_id is required for the API to match results to calls - messages.push(CompletionMessage { - role: "tool".to_string(), - content: serde_json::Value::String(format!( - "ERROR: {}\n\nPlease correct the tool call format and try again.", - tool_call.query - )), - tool_call_id: Some(tool_call_id), - tool_calls: None, - }); - return Ok(tools::ToolExecutionResult::Success); + if let Some(tool_choice) = tool_choice { + let tool_choice = serde_json::to_value(tool_choice).map_err(|e| { + errors::ResponseError::InternalError(format!( + "Failed to serialize custom function choice: {e}" + )) + })?; + extra.insert("tool_choice".to_string(), tool_choice); } - { - let mut event_ctx = tools::ToolEventContext { - stream_ctx: ctx, - emitter, - tool_call_id: &tool_call_id, - response_items_repository: Some(&process_context.response_items_repository), - }; - process_context - .tool_registry - .emit_start(tool_call, &mut event_ctx) - .await?; + if let Some(signing_algo) = &process_context.signing_algo { + extra.insert( + encryption_headers::SIGNING_ALGO.to_string(), + serde_json::Value::String(signing_algo.clone()), + ); + } + if let Some(client_pub_key) = &process_context.client_pub_key { + extra.insert( + encryption_headers::CLIENT_PUB_KEY.to_string(), + serde_json::Value::String(client_pub_key.clone()), + ); + } + if let Some(model_pub_key) = &process_context.model_pub_key { + extra.insert( + encryption_headers::MODEL_PUB_KEY.to_string(), + serde_json::Value::String(model_pub_key.clone()), + ); + } + if let Some(encryption_version) = &process_context.encryption_version { + extra.insert( + encryption_headers::ENCRYPTION_VERSION.to_string(), + serde_json::Value::String(encryption_version.clone()), + ); } - // Execute the tool using the registry - let exec_context = tools::ToolExecutionContext { - request: &process_context.request, + let completion_request = CompletionRequest { + request_id: process_context.request_id, + model: process_context.request.model.clone(), + messages: messages.to_vec(), + max_tokens: process_context.request.max_output_tokens, + temperature: process_context.request.temperature, + top_p: process_context.request.top_p, + stop: None, + stream: Some(true), + user_id: process_context.user_id.clone(), + api_key_id: process_context.api_key_id.to_string(), + organization_id: process_context.organization_id, + workspace_id: process_context.workspace_id, + metadata: process_context.request.metadata.clone(), + store: process_context.request.store, + body_hash: process_context.body_hash.clone(), + response_id: None, + skip_provider_chat_signature: false, + original_request: None, + n: None, + service_tier: Some(inference_providers::ChatServiceTier::Default), + extra, }; - let tool_output = match process_context - .tool_registry - .execute(tool_call, &exec_context) + let mut completion_stream = process_context + .completion_service + .create_chat_completion_stream(completion_request) .await - { - Ok(output) => Some(output), - - Err(e) => { - // Check if this is a function call that needs client execution - let is_function_call = - matches!(&e, errors::ResponseError::FunctionCallRequired { .. }); - let function_call_info = - if let errors::ResponseError::FunctionCallRequired { name, call_id } = &e { - Some(tools::FunctionCallInfo { - call_id: call_id.clone(), - name: name.clone(), - arguments: tool_call - .params - .as_ref() - .map(|p| serde_json::to_string(p).unwrap_or_default()) - .unwrap_or_default(), - }) - } else { - None - }; - - // Delegate error handling to the executor (e.g., MCP approval flow) - let mut event_ctx = tools::ToolEventContext { - stream_ctx: ctx, - emitter, - tool_call_id: &tool_call_id, - response_items_repository: Some(&process_context.response_items_repository), - }; - - match process_context - .tool_registry - .handle_error(e, tool_call, &mut event_ctx) - .await? - { - Some(output) => Some(output), - None => { - // None means special control flow - // For function calls, return FunctionCallPending so we can batch them - // For MCP approval, return ApprovalRequired - if is_function_call { - return Ok(tools::ToolExecutionResult::FunctionCallPending( - function_call_info.expect("function_call_info should be set"), - )); - } - return Ok(tools::ToolExecutionResult::ApprovalRequired); - } - } - } - }; - - // If we got here with None, something went wrong - let tool_output = match tool_output { - Some(output) => output, - None => return Ok(tools::ToolExecutionResult::ApprovalRequired), - }; - - // Handle tool-specific side effects via pattern matching - let (tool_content, instruction) = match tool_output { - tools::ToolOutput::WebSearch { sources, .. } => { - // Calculate start index based on current registry size - let start_index = process_context - .source_registry - .as_ref() - .map(|r| r.web_sources.len()) - .unwrap_or(0); - - // Format content with correct cumulative indices - // format_results returns FormattedWebSearchResult with formatted text and optional instruction - let result = tools::web_search::format_results(&sources, start_index); - - // Accumulate sources into registry - if let Some(ref mut registry) = process_context.source_registry { - registry.web_sources.extend(sources); - } else { - process_context.source_registry = - Some(models::SourceRegistry::with_results(sources)); - } - - // Reset failure counter on successful web search - process_context.web_search_failure_count = 0; + .map_err(errors::ResponseError::from)?; + + Self::process_completion_stream( + &mut completion_stream, + emitter, + ctx, + &process_context.response_items_repository, + process_context, + ) + .await + } - (result.formatted, result.instruction) - } - tools::ToolOutput::FileSearch { results } => { - // Format file search results - let formatted = - tools::file_search::FileSearchToolExecutor::format_results(&results); - (formatted, None) - } - tools::ToolOutput::Text(content) => { - // Plain text has no side effects - (content, None) - } - }; + /// Emit model-selected custom functions for the client to execute. No + /// function is executed or retried by Cloud. + async fn emit_client_function_calls( + ctx: &mut crate::responses::service_helpers::ResponseStreamContext, + emitter: &mut crate::responses::service_helpers::EventEmitter, + response_items_repository: &Arc, + tool_calls: Vec, + ) -> Result { + if tool_calls.is_empty() { + return Ok(false); + } - // Emit tool-specific completion events via registry - { - let mut event_ctx = tools::ToolEventContext { - stream_ctx: ctx, - emitter, - tool_call_id: &tool_call_id, - response_items_repository: Some(&process_context.response_items_repository), + for tool_call in tool_calls { + let call_id = tool_call.id; + let function_call = models::ResponseOutputItem::FunctionCall { + id: format!( + "{}{}", + crate::id_prefixes::PREFIX_FC, + Uuid::new_v4().simple() + ), + response_id: ctx.response_id_str.clone(), + previous_response_id: ctx.previous_response_id.clone(), + next_response_ids: vec![], + created_at: ctx.created_at, + call_id: call_id.clone(), + name: tool_call.name, + arguments: tool_call.arguments, + thought_signature: tool_call.thought_signature, + status: "in_progress".to_string(), + model: ctx.model.clone(), }; - process_context - .tool_registry - .emit_complete(tool_call, &mut event_ctx) - .await?; - } - // Add tool result to message history with matching tool_call_id - // This is REQUIRED by all providers for the agent loop to work correctly - messages.push(CompletionMessage { - role: "tool".to_string(), - content: serde_json::Value::String(tool_content), - tool_call_id: Some(tool_call_id), - tool_calls: None, - }); + response_items_repository + .create( + ctx.response_id.clone(), + ctx.api_key_id, + ctx.conversation_id, + function_call.clone(), + ) + .await + .map_err(|e| { + errors::ResponseError::InternalError(format!( + "Failed to store client-managed function call: {e}" + )) + })?; - // Defer citation instruction to be added after all tool results - // This ensures tool results are consecutive (required by OpenAI/Anthropic/Gemini) - if let Some(instruction) = instruction { - deferred_instructions.push(instruction); + // Keep the event shape compatible with the prior function + // executor: `item_id` is the model call ID used by clients to + // correlate the later function_call_output. + emitter.emit_item_added(ctx, function_call, call_id).await?; } - Ok(tools::ToolExecutionResult::Success) + Ok(true) } - /// Validate MCP approval-response references from the request input. + /// Convert accumulated provider tool-call chunks into client-managed + /// function calls without touching their argument bytes. /// - /// Runs before the response row is created so an invalid, unknown, or - /// foreign `approval_request_id` fails fast without persisting anything. - /// Mirrors the checks in `tools::mcp::process_approval_responses` (which - /// still runs later as defense in depth): workspace-scoped lookup, same - /// non-enumerating not-found for unknown and foreign IDs, and a type - /// check that the referenced item is an approval request. - /// - /// Only active when the request configures MCP tools, matching when - /// approval responses are actually processed. - async fn validate_mcp_approval_references( + /// Unlike `tools::convert_tool_calls`, this deliberately does not infer a + /// missing name, parse JSON, repair malformed JSON, or apply builtin + /// search-specific handling. The only checks are that the provider named a + /// declared custom function and supplied a usable call ID (or omitted one, + /// in which case Cloud creates the correlation ID once). + fn convert_client_function_calls( + tool_call_accumulator: crate::responses::service_helpers::ToolCallAccumulator, request: &models::CreateResponseRequest, - response_items_repository: &Arc, - workspace_id: crate::workspace::WorkspaceId, - ) -> Result<(), errors::ResponseError> { - let has_mcp_tools = request.tools.as_ref().is_some_and(|tools| { - tools - .iter() - .any(|t| matches!(t, models::ResponseTool::Mcp { .. })) - }); - if !has_mcp_tools { - return Ok(()); - } - - let approval_ids: Vec<&str> = match &request.input { - Some(models::ResponseInput::Items(items)) => items - .iter() - .filter_map(|item| item.as_mcp_approval()) - .map(|(approval_request_id, _approve)| approval_request_id) - .collect(), - _ => return Ok(()), - }; + ) -> Result< + Vec, + errors::ResponseError, + > { + let declared_function_names: HashSet<&str> = request + .tools + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|tool| match tool { + models::ResponseTool::Function { name, .. } => Some(name.as_str()), + _ => None, + }) + .collect(); - for approval_request_id in approval_ids { - let uuid_str = approval_request_id - .strip_prefix(crate::id_prefixes::PREFIX_MCPR) - .unwrap_or(approval_request_id); - let item_uuid = Uuid::parse_str(uuid_str).map_err(|e| { - errors::ResponseError::InvalidParams(format!("Invalid approval_request_id: {e}")) - })?; + let mut entries: Vec<_> = tool_call_accumulator.into_iter().collect(); + entries.sort_by_key(|(index, _)| *index); - let item = response_items_repository - .get_by_id(models::ResponseItemId(item_uuid), workspace_id.clone()) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!( - "Failed to fetch approval request: {e}" + entries + .into_iter() + .map(|(index, entry)| { + let name = entry.name.ok_or_else(|| { + errors::ResponseError::InvalidParams(format!( + "Model returned an invalid custom function call at index {index}: missing function name." )) })?; - - match item { - None => { - return Err(errors::ResponseError::McpApprovalRequestNotFound( - approval_request_id.to_string(), - )); + if name.trim().is_empty() { + return Err(errors::ResponseError::InvalidParams(format!( + "Model returned an invalid custom function call at index {index}: missing function name." + ))); } - Some(models::ResponseOutputItem::McpApprovalRequest { .. }) => {} - Some(_) => { + if !declared_function_names.contains(name.as_str()) { return Err(errors::ResponseError::InvalidParams(format!( - "Item {} is not an MCP approval request", - approval_request_id + "Model called unsupported custom function '{name}'." ))); } - } - } - Ok(()) + let id = match entry.id { + Some(id) if id.trim().is_empty() => { + return Err(errors::ResponseError::InvalidParams(format!( + "Model returned an invalid custom function call '{name}': empty call_id." + ))); + } + Some(id) => id, + None => format!("{name}_{}", Uuid::new_v4().simple()), + }; + + Ok(crate::responses::service_helpers::ClientManagedFunctionCall { + id, + name, + arguments: entry.arguments, + thought_signature: entry.thought_signature, + }) + }) + .collect() } /// Store request input as response items and return the IDs that originated @@ -2222,13 +1623,14 @@ impl ResponseServiceImpl { call_id, name, arguments, + thought_signature, .. } => { pending_function_calls.push(crate::completions::ports::CompletionToolCall { id: call_id.clone(), name: name.clone(), arguments: arguments.clone(), - thought_signature: None, + thought_signature: thought_signature.clone(), }); true } @@ -2483,6 +1885,7 @@ impl ResponseServiceImpl { call_id, name, arguments, + thought_signature, .. } => { // If this is from a different response than pending, flush first @@ -2500,7 +1903,7 @@ impl ResponseServiceImpl { id: call_id, name, arguments, - thought_signature: None, + thought_signature, }); } models::ResponseOutputItem::FunctionCallOutput { @@ -2988,282 +2391,6 @@ impl ResponseServiceImpl { } } - /// Check if conversation needs title generation and spawn background task if needed - /// Returns a JoinHandle that can be awaited to ensure title generation completes before response finishes - #[allow(clippy::too_many_arguments)] - fn maybe_generate_conversation_title( - conversation_id: Option, - request: &models::CreateResponseRequest, - user_id: crate::UserId, - api_key_id: String, - request_id: uuid::Uuid, - organization_id: uuid::Uuid, - workspace_id: uuid::Uuid, - conversation_service: Arc, - completion_service: Arc, - tx: futures::channel::mpsc::UnboundedSender, - signing_algo: Option, - client_pub_key: Option, - ) -> Option>> { - // Skip title generation if request is encrypted - // (both headers X-Signing-Algo and X-Client-Pub-Key are set) - if signing_algo.is_some() && client_pub_key.is_some() { - return None; - } - - // Only proceed if we have a conversation_id - let conv_id = conversation_id?; - - // Extract first user message from request - let user_message = match &request.input { - Some(models::ResponseInput::Text(text)) => text.clone(), - Some(models::ResponseInput::Items(items)) => { - // Find first user message - items - .iter() - .filter_map(|item| match item { - models::ResponseInputItem::Message { role, content, .. } - if role == "user" => - { - Some(content) - } - _ => None, - }) - .next() - .and_then(|content| match content { - models::ResponseContent::Text(text) => Some(text.clone()), - models::ResponseContent::Parts(parts) => { - // Extract text from parts - let text = parts - .iter() - .filter_map(|part| match part { - models::ResponseContentPart::InputText { text } => { - Some(text.clone()) - } - _ => None, - }) - .collect::>() - .join("\n"); - if text.is_empty() { - None - } else { - Some(text) - } - } - }) - .unwrap_or_default() - } - None => return None, - }; - - if user_message.is_empty() { - return None; - } - - // Spawn background task to check and generate title - let handle = tokio::spawn(async move { - Self::generate_and_update_title( - conv_id, - user_id, - user_message, - api_key_id, - request_id, - organization_id, - workspace_id, - conversation_service, - completion_service, - tx, - ) - .await - }); - - Some(handle) - } - - /// Generate conversation title and update metadata (background task) - #[allow(clippy::too_many_arguments)] - async fn generate_and_update_title( - conversation_id: ConversationId, - user_id: crate::UserId, - user_message: String, - api_key_id: String, - request_id: uuid::Uuid, - organization_id: uuid::Uuid, - workspace_id: uuid::Uuid, - conversation_service: Arc, - completion_service: Arc, - mut tx: futures::channel::mpsc::UnboundedSender, - ) -> Result<(), errors::ResponseError> { - // Get conversation to check if it already has a title - let workspace_id_domain = crate::workspace::WorkspaceId(workspace_id); - let conversation = conversation_service - .get_conversation(conversation_id, workspace_id_domain.clone()) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!("Failed to get conversation: {e}")) - })?; - - let conversation = match conversation { - Some(c) => c, - None => { - tracing::debug!("Conversation not found, skipping title generation"); - return Ok(()); - } - }; - - // Check if conversation already has a title - if let Some(title) = conversation.metadata.get("title") { - if !title.is_null() && title.as_str().is_some() { - tracing::debug!("Conversation already has a title, skipping generation"); - return Ok(()); - } - } - - // Truncate user message for title generation (max 500 chars for context) - // Use iterator to safely handle UTF-8 (cannot panic) - let mut chars = user_message.chars(); - let truncated_message: String = chars.by_ref().take(500).collect(); - let truncated_message = if chars.next().is_some() { - format!("{truncated_message}...") - } else { - truncated_message - }; - - // Create prompt for title generation - let title_prompt = format!( - "Generate a short, descriptive title (maximum 60 characters) for a conversation that starts with this message. \ - Only respond with the title, nothing else.\n\nMessage: {truncated_message}" - ); - - // Generate title using completion service (names not included - tracked via database) - let title_model = std::env::var("TITLE_GENERATION_MODEL") - .unwrap_or_else(|_| "Qwen/Qwen3-30B-A3B-Instruct-2507".to_string()); - let completion_request = crate::completions::ports::CompletionRequest { - request_id, - model: title_model, - messages: vec![crate::completions::ports::CompletionMessage { - role: "user".to_string(), - content: serde_json::Value::String(title_prompt), - tool_call_id: None, - tool_calls: None, - }], - max_tokens: Some(150), - temperature: Some(1.0), - top_p: None, - stop: None, - stream: Some(false), - user_id: user_id.clone(), - api_key_id, // Use the same API key as the user's request - organization_id, - workspace_id, - metadata: None, - store: None, - body_hash: String::new(), - response_id: None, // Title generation is not tied to a specific response - skip_provider_chat_signature: false, - original_request: None, - n: None, - service_tier: Some(inference_providers::ChatServiceTier::Default), - extra: std::collections::HashMap::from([( - "chat_template_kwargs".to_string(), - serde_json::json!({ "enable_thinking": false }), - )]), - }; - - // Call completion service to generate title - let completion_result = completion_service - .create_chat_completion(completion_request) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!("Failed to generate title: {e}")) - })?; - - // Extract title from completion result - let raw_title = completion_result - .response - .choices - .first() - .and_then(|choice| choice.message.content.as_ref()) - .map(|content| content.trim().to_string()); - let raw_title = if let Some(title) = raw_title { - title - } else { - tracing::warn!( - conversation_id = %conversation_id, - "LLM response doesn't contain title for conversation, using default" - ); - "Conversation".to_string() - }; - - // Strip reasoning tags from title - let mut reasoning_buffer = String::new(); - let mut inside_reasoning = false; - let (generated_title, _, _) = - Self::process_reasoning_tags(&raw_title, &mut reasoning_buffer, &mut inside_reasoning); - let generated_title = generated_title.trim(); - let generated_title = if generated_title.is_empty() { - "Conversation".to_string() - } else { - generated_title.to_string() - }; - - // Truncate to max 60 characters (iterator approach cannot panic) - let mut chars = generated_title.chars(); - let title: String = chars.by_ref().take(57).collect(); - let title = if chars.next().is_some() { - format!("{title}...") - } else { - generated_title - }; - - // Update conversation metadata with title - let mut updated_metadata = conversation.metadata.clone(); - updated_metadata["title"] = serde_json::Value::String(title.clone()); - - let workspace_id_domain = crate::workspace::WorkspaceId(workspace_id); - conversation_service - .update_conversation(conversation_id, workspace_id_domain, updated_metadata) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!( - "Failed to update conversation metadata: {e}" - )) - })?; - - tracing::info!( - conversation_id = %conversation_id, - title_length = title.len(), - truncated = title.len() > 60, - "Generated conversation title" - ); - // Emit conversation.title.updated event - use futures::SinkExt; - let event = models::ResponseStreamEvent { - event_type: "conversation.title.updated".to_string(), - sequence_number: None, // No sequence number for background events - response: None, - output_index: None, - content_index: None, - item: None, - item_id: None, - part: None, - delta: None, - text: None, - error: None, - status_code: None, - logprobs: None, - obfuscation: None, - annotation_index: None, - annotation: None, - conversation_title: Some(title), - usage: None, - }; - - let _ = tx.send(event).await; - - Ok(()) - } - /// Check if a model has image generation capability based on output_modalities fn has_image_generation_capability(output_modalities: &Option>) -> bool { output_modalities @@ -3742,6 +2869,7 @@ mod tests { call_id: "call_weather".to_string(), name: "get_weather".to_string(), arguments: r#"{"location":"Shanghai"}"#.to_string(), + thought_signature: Some("gemini-thought-signature".to_string()), }, models::ResponseInputItem::FunctionCallOutput { type_: models::FunctionCallOutputType::FunctionCallOutput, @@ -3774,6 +2902,10 @@ mod tests { assert_eq!(tool_calls[0].id, "call_weather"); assert_eq!(tool_calls[0].name, "get_weather"); assert_eq!(tool_calls[0].arguments, r#"{"location":"Shanghai"}"#); + assert_eq!( + tool_calls[0].thought_signature.as_deref(), + Some("gemini-thought-signature") + ); assert_eq!(messages[1].role, "tool"); assert_eq!(messages[1].tool_call_id.as_deref(), Some("call_weather")); @@ -3783,6 +2915,109 @@ mod tests { ); } + #[test] + fn custom_function_conversion_preserves_raw_arguments_without_builtin_repair() { + let request = models::CreateResponseRequest { + model: "test-model".to_string(), + input: None, + instructions: None, + conversation: None, + previous_response_id: None, + max_output_tokens: None, + max_tool_calls: None, + temperature: None, + top_p: None, + stream: None, + store: Some(false), + background: Some(false), + tools: Some(vec![models::ResponseTool::Function { + // A custom function may intentionally share the legacy builtin + // name. It must not receive web-search JSON repair. + name: WEB_SEARCH_TOOL_NAME.to_string(), + description: None, + parameters: None, + }]), + tool_choice: None, + parallel_tool_calls: None, + reasoning: None, + include: None, + metadata: None, + safety_identifier: None, + prompt_cache_key: None, + service_tier: None, + }; + let raw_arguments = " {\"query\": \"Shanghai weather\", }\n"; + let mut accumulated = crate::responses::service_helpers::ToolCallAccumulator::default(); + accumulated.insert( + 0, + crate::responses::service_helpers::ToolCallAccumulatorEntry { + id: Some("call_raw".to_string()), + name: Some(WEB_SEARCH_TOOL_NAME.to_string()), + arguments: raw_arguments.to_string(), + thought_signature: Some("gemini-thought-signature".to_string()), + }, + ); + + let calls = ResponseServiceImpl::convert_client_function_calls(accumulated, &request) + .expect("declared custom function should be accepted without parsing arguments"); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].id, "call_raw"); + assert_eq!(calls[0].name, WEB_SEARCH_TOOL_NAME); + assert_eq!(calls[0].arguments, raw_arguments); + assert_eq!( + calls[0].thought_signature.as_deref(), + Some("gemini-thought-signature") + ); + } + + #[test] + fn custom_function_conversion_generates_a_call_id_only_when_omitted() { + let request = models::CreateResponseRequest { + model: "test-model".to_string(), + input: None, + instructions: None, + conversation: None, + previous_response_id: None, + max_output_tokens: None, + max_tool_calls: None, + temperature: None, + top_p: None, + stream: None, + store: Some(false), + background: Some(false), + tools: Some(vec![models::ResponseTool::Function { + name: "lookup".to_string(), + description: None, + parameters: None, + }]), + tool_choice: None, + parallel_tool_calls: None, + reasoning: None, + include: None, + metadata: None, + safety_identifier: None, + prompt_cache_key: None, + service_tier: None, + }; + let mut accumulated = crate::responses::service_helpers::ToolCallAccumulator::default(); + accumulated.insert( + 0, + crate::responses::service_helpers::ToolCallAccumulatorEntry { + id: None, + name: Some("lookup".to_string()), + arguments: "not JSON".to_string(), + thought_signature: None, + }, + ); + + let calls = ResponseServiceImpl::convert_client_function_calls(accumulated, &request) + .expect("missing provider ID should receive a generated correlation ID"); + + assert!(calls[0].id.starts_with("lookup_")); + assert_eq!(calls[0].arguments, "not JSON"); + } + #[test] fn parallel_replayed_function_calls_are_grouped_before_their_outputs() { let items = vec![ @@ -3791,12 +3026,14 @@ mod tests { call_id: "call_weather".to_string(), name: "get_weather".to_string(), arguments: r#"{"location":"Shanghai"}"#.to_string(), + thought_signature: None, }, models::ResponseInputItem::FunctionCall { type_: models::FunctionCallType::FunctionCall, call_id: "call_time".to_string(), name: "get_time".to_string(), arguments: r#"{"timezone":"Asia/Shanghai"}"#.to_string(), + thought_signature: None, }, models::ResponseInputItem::FunctionCallOutput { type_: models::FunctionCallOutputType::FunctionCallOutput, @@ -3840,39 +3077,6 @@ mod tests { assert_eq!(messages[2].tool_call_id.as_deref(), Some("call_time")); } - #[test] - fn custom_function_name_colliding_with_discovered_mcp_tool_is_rejected() { - let request: models::CreateResponseRequest = serde_json::from_value(serde_json::json!({ - "model": "test-model", - "store": false, - "tools": [{ - "type": "function", - "name": "weather:get_weather", - "parameters": {"type": "object"} - }] - })) - .expect("test request should deserialize"); - let mcp_tool_definitions = vec![inference_providers::ToolDefinition { - type_: "function".to_string(), - function: inference_providers::FunctionDefinition { - name: "weather:get_weather".to_string(), - description: None, - parameters: serde_json::json!({"type": "object"}), - }, - }]; - - let error = ResponseServiceImpl::validate_custom_function_tool_name_collisions( - &request, - &mcp_tool_definitions, - ) - .expect_err("same-name custom function must not be claimed by MCP"); - - assert!(matches!(error, errors::ResponseError::InvalidParams(_))); - assert!(error - .to_string() - .contains("conflicts with a configured or discovered server-executed tool")); - } - #[test] fn test_process_reasoning_tags_simple_think() { let mut reasoning_buffer = String::new(); diff --git a/crates/services/src/responses/service_helpers.rs b/crates/services/src/responses/service_helpers.rs index d98034145..34a390e58 100644 --- a/crates/services/src/responses/service_helpers.rs +++ b/crates/services/src/responses/service_helpers.rs @@ -580,6 +580,19 @@ pub struct ToolCallInfo { pub thought_signature: Option, } +/// A custom function call that Cloud returns verbatim for the client to run. +/// +/// Unlike the legacy server-executed tool representation, `arguments` is not +/// parsed, repaired, or reserialized. A client may replay it exactly alongside +/// its `function_call_output` in a subsequent stateless request. +#[derive(Debug, Clone)] +pub struct ClientManagedFunctionCall { + pub id: String, + pub name: String, + pub arguments: String, + pub thought_signature: Option, +} + /// Result of processing a completion stream /// /// Contains the accumulated text, detected tool calls, and stream status. @@ -588,7 +601,7 @@ pub struct ProcessStreamResult { /// The accumulated text content from the stream pub text: String, /// Tool calls detected in the stream - pub tool_calls: Vec, + pub tool_calls: Vec, /// Whether the stream terminated with an error (client disconnect, network error, etc.) /// When true, the response may be incomplete and the agent loop should stop. pub stream_error: bool, diff --git a/crates/services/src/responses/tools/function.rs b/crates/services/src/responses/tools/function.rs index 5f85aa332..eeb7e320a 100644 --- a/crates/services/src/responses/tools/function.rs +++ b/crates/services/src/responses/tools/function.rs @@ -123,6 +123,7 @@ impl ToolExecutor for FunctionToolExecutor { call_id: call_id.clone(), name: name.clone(), arguments, + thought_signature: tool_call.thought_signature.clone(), status: "in_progress".to_string(), model: event_ctx.stream_ctx.model.clone(), }; diff --git a/crates/services/src/responses/transient.rs b/crates/services/src/responses/transient.rs index 21b9cb8a0..7f3a777da 100644 --- a/crates/services/src/responses/transient.rs +++ b/crates/services/src/responses/transient.rs @@ -59,20 +59,6 @@ fn response_id_string(response_id: Uuid) -> String { format!("resp_{}", response_id.simple()) } -fn default_tools() -> Vec { - vec![models::ResponseTool::WebSearch { - filters: None, - search_context_size: Some("medium".to_string()), - user_location: Some(models::UserLocation { - type_: "approximate".to_string(), - city: None, - country: Some("US".to_string()), - region: None, - timezone: None, - }), - }] -} - fn initial_response(request: models::CreateResponseRequest) -> (Uuid, models::ResponseObject) { let response_id = Uuid::new_v4(); let now = chrono::Utc::now().timestamp(); @@ -106,7 +92,10 @@ fn initial_response(request: models::CreateResponseRequest) -> (Uuid, models::Re store: false, temperature: request.temperature.unwrap_or(1.0), tool_choice: models::ResponseToolChoiceOutput::Auto("auto".to_string()), - tools: request.tools.unwrap_or_else(default_tools), + // Stateless Responses only supports caller-defined functions. + // Never advertise the formerly implicit server-side web_search + // builtin when the caller did not configure any tools. + tools: request.tools.unwrap_or_default(), top_logprobs: 0, top_p: request.top_p.unwrap_or(1.0), truncation: "disabled".to_string(), @@ -519,6 +508,10 @@ mod tests { assert!(!response.store); assert!(!response.background); assert!(response.conversation.is_none()); + assert!( + response.tools.is_empty(), + "a no-tools request must not advertise the retired web_search builtin" + ); let response_id = models::ResponseId( Uuid::parse_str(response.id.strip_prefix("resp_").unwrap()) From 7c4498376e10c31e07a94cdea217aa444801c784 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:37:59 +0800 Subject: [PATCH 18/31] test(responses): document client-managed tool contract --- crates/api/src/lib.rs | 48 ++ crates/api/src/openapi.rs | 2 +- crates/api/tests/common/mod.rs | 17 +- crates/api/tests/e2e_all/main.rs | 3 - crates/api/tests/e2e_all/mcp.rs | 177 ----- .../api/tests/e2e_all/responses_stateless.rs | 110 +++ .../api/tests/e2e_all/web_context_search.rs | 145 ---- .../api/tests/e2e_all/web_search_citations.rs | 637 ------------------ crates/services/src/responses/models.rs | 20 + docs/local-development.md | 14 +- 10 files changed, 204 insertions(+), 969 deletions(-) delete mode 100644 crates/api/tests/e2e_all/mcp.rs delete mode 100644 crates/api/tests/e2e_all/web_context_search.rs delete mode 100644 crates/api/tests/e2e_all/web_search_citations.rs diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index b6e8c7b8e..ff9e5d449 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -2651,6 +2651,54 @@ mod tests { } } + #[test] + fn test_openapi_responses_only_advertises_client_managed_function_tools() { + let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let request_tools_schema = &spec["components"]["schemas"] + ["StatelessCreateResponseRequestSchema"]["properties"]["tools"]; + assert!( + serde_json::to_string(request_tools_schema) + .unwrap() + .contains("StatelessResponseToolSchema"), + "Responses create-request schema must reference only the client-managed tool schema" + ); + + let response_tool_schema = &spec["components"]["schemas"]["ClientManagedResponseTool"]; + assert!( + response_tool_schema.is_object(), + "Responses OpenAPI schema must describe its supported tool type" + ); + + let serialized_tool_schema = serde_json::to_string(response_tool_schema).unwrap(); + assert!( + serialized_tool_schema.contains("function"), + "Responses OpenAPI schema must retain custom function tools" + ); + for server_executed_tool in [ + "web_search", + "web_context_search", + "file_search", + "code_interpreter", + "computer", + "mcp", + ] { + assert!( + !serialized_tool_schema.contains(server_executed_tool), + "Responses OpenAPI schema must not advertise the rejected {server_executed_tool} tool" + ); + } + + let responses_tag = spec["tags"] + .as_array() + .and_then(|tags| tags.iter().find(|tag| tag["name"] == "Responses")) + .expect("Responses tag must be present in OpenAPI"); + let description = responses_tag["description"] + .as_str() + .expect("Responses tag must document its contract"); + assert!(description.contains("Only custom `function` tools are supported")); + assert!(description.contains("POST /mcp")); + } + #[test] fn test_openapi_signature_requires_api_key() { // nearai/infra#193: /v1/signature/{chat_id} stays API-key-protected. diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 5d0982050..cbb9f18b4 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -25,7 +25,7 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Custom function tools are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Conversations, response history, file input/search, code-interpreter/computer tools, and MCP approval modes other than `require_approval: \"never\"` are rejected."), + (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Only custom `function` tools are supported. They are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Server-executed tools (`web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`) are rejected. The separate `POST /mcp` endpoint continues to expose its `web_search` tool independently of Responses. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Conversations, response history, and file input are rejected."), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), diff --git a/crates/api/tests/common/mod.rs b/crates/api/tests/common/mod.rs index bf7f657d7..d30cdb8a4 100644 --- a/crates/api/tests/common/mod.rs +++ b/crates/api/tests/common/mod.rs @@ -30,7 +30,10 @@ use services::responses::tools::{ }; use services::usage::ModelPricing; use sha2::{Digest, Sha256}; -use std::sync::{Arc, Mutex}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, +}; #[cfg(test)] use ed25519_dalek::{Signature as Ed25519Signature, VerifyingKey as Ed25519VerifyingKey}; @@ -240,11 +243,15 @@ async fn build_test_server_components_with_real_providers( /// Mock web search provider for e2e tests. Returns a fixed list of results without calling Brave. pub struct MockWebSearchProvider { results: Vec, + call_count: Arc, } impl MockWebSearchProvider { pub fn new(results: Vec) -> Self { - Self { results } + Self { + results, + call_count: Arc::new(AtomicUsize::new(0)), + } } /// Default mock with one placeholder result. @@ -255,6 +262,11 @@ impl MockWebSearchProvider { snippet: "Snippet from mock web search.".to_string(), }]) } + + /// Number of provider invocations observed by this mock. + pub fn call_count(&self) -> Arc { + self.call_count.clone() + } } #[async_trait] @@ -263,6 +275,7 @@ impl WebSearchProviderTrait for MockWebSearchProvider { &self, _params: WebSearchParams, ) -> Result, WebSearchError> { + self.call_count.fetch_add(1, Ordering::SeqCst); Ok(self.results.clone()) } } diff --git a/crates/api/tests/e2e_all/main.rs b/crates/api/tests/e2e_all/main.rs index 71185a1de..bf6bb4ac4 100644 --- a/crates/api/tests/e2e_all/main.rs +++ b/crates/api/tests/e2e_all/main.rs @@ -46,7 +46,6 @@ mod glm52_tier_routing; mod health; mod invitations; mod ita_attestation; -mod mcp; mod mcp_server; mod message_metadata; mod model_alias_transparency; @@ -77,5 +76,3 @@ mod usage_provider_attribution; mod usage_recording; mod usage_responses; mod vpc_login; -mod web_context_search; -mod web_search_citations; diff --git a/crates/api/tests/e2e_all/mcp.rs b/crates/api/tests/e2e_all/mcp.rs deleted file mode 100644 index a312669d9..000000000 --- a/crates/api/tests/e2e_all/mcp.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! E2E coverage for retired client-mediated MCP flows. -//! -//! The stateless Responses API still permits server-side MCP work that can -//! finish in one request, but it cannot retain an approval request for a -//! client to resume later. - -use crate::common::*; -use inference_providers::mock::{RequestMatcher, ResponseTemplate, ToolCall}; -use services::responses::models::McpDiscoveredTool; -use services::responses::tools::{MockMcpClient, MockMcpClientFactory}; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; - -#[tokio::test] -async fn mcp_tools_without_approval_complete_in_one_stateless_request() { - let list_tools_calls = Arc::new(AtomicUsize::new(0)); - let tool_calls = Arc::new(AtomicUsize::new(0)); - let list_tools_calls_for_factory = list_tools_calls.clone(); - let tool_calls_for_factory = tool_calls.clone(); - - let mut mock_factory = MockMcpClientFactory::new(); - mock_factory - .expect_create_client() - .withf(|url: &str, _| url == "https://example.com/mcp") - .returning(move |_, _| { - let list_tools_calls = list_tools_calls_for_factory.clone(); - let tool_calls = tool_calls_for_factory.clone(); - let mut client = MockMcpClient::new(); - - client.expect_list_tools().returning(move || { - list_tools_calls.fetch_add(1, Ordering::SeqCst); - Ok(vec![McpDiscoveredTool { - name: "get_weather".to_string(), - description: Some("Get weather for a location".to_string()), - input_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"] - })), - annotations: None, - }]) - }); - client - .expect_call_tool() - .withf(|name: &str, arguments| { - name == "get_weather" && arguments["location"] == "San Francisco" - }) - .returning(move |_, _| { - tool_calls.fetch_add(1, Ordering::SeqCst); - Ok("Weather in San Francisco: Sunny, 72°F".to_string()) - }); - - Ok(Box::new(client) as Box) - }); - - let (server, _pool, mock) = setup_test_server_with_mcp_factory(Arc::new(mock_factory)).await; - let model = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - let prompt = "What's the weather in San Francisco?"; - mock.when(RequestMatcher::PromptWithTools { - prompt: mock_prompts::build_prompt(prompt), - tool_names: vec!["weather:get_weather".to_string()], - }) - .respond_with( - ResponseTemplate::new("").with_tool_calls(vec![ToolCall::new( - "weather:get_weather", - serde_json::json!({"location": "San Francisco"}).to_string(), - )]), - ) - .await; - mock.set_default_response(ResponseTemplate::new( - "The weather in San Francisco is sunny and 72°F.", - )) - .await; - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": model, - "input": prompt, - "store": false, - "stream": false, - "tools": [{ - "type": "mcp", - "server_label": "weather", - "server_url": "https://example.com/mcp", - "require_approval": "never" - }] - })) - .await; - - assert_eq!( - response.status_code(), - 200, - "stateless MCP request failed: {}", - response.text() - ); - let response = response.json::(); - assert_eq!(response["status"], "completed"); - assert_eq!(list_tools_calls.load(Ordering::SeqCst), 1); - assert_eq!(tool_calls.load(Ordering::SeqCst), 1); - - let output = response["output"] - .as_array() - .expect("response output should be an array"); - let discovered = output - .iter() - .find(|item| item["type"] == "mcp_list_tools") - .expect("MCP tools should be discovered during the request"); - assert_eq!(discovered["server_label"], "weather"); - assert_eq!(discovered["tools"][0]["name"], "get_weather"); - assert!( - output - .iter() - .all(|item| item["type"] != "mcp_approval_request"), - "require_approval=never must not produce a resumable approval request" - ); -} - -#[tokio::test] -async fn mcp_tools_requiring_approval_are_rejected_by_stateless_responses() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "test-model", - "input": "Check the weather", - "store": false, - "tools": [{ - "type": "mcp", - "server_label": "weather", - "server_url": "https://example.com/mcp", - "require_approval": "always" - }] - })) - .await; - - assert_eq!(response.status_code(), 400); - let error = response.json::(); - assert_eq!(error.error.r#type, "invalid_request_error"); - assert!(error.error.message.contains("require approval")); -} - -#[tokio::test] -async fn mcp_approval_continuations_are_rejected_by_stateless_responses() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({ - "model": "test-model", - "store": false, - "input": [{ - "type": "mcp_approval_response", - "approval_request_id": "mcpr_example", - "approve": true - }] - })) - .await; - - assert_eq!(response.status_code(), 400); - let error = response.json::(); - assert_eq!(error.error.r#type, "invalid_request_error"); - assert!(error.error.message.contains("MCP approval continuation")); -} diff --git a/crates/api/tests/e2e_all/responses_stateless.rs b/crates/api/tests/e2e_all/responses_stateless.rs index d12097857..5f36a787f 100644 --- a/crates/api/tests/e2e_all/responses_stateless.rs +++ b/crates/api/tests/e2e_all/responses_stateless.rs @@ -2,6 +2,7 @@ use crate::common::*; use axum::http::Method; +use std::sync::{atomic::Ordering, Arc}; fn assert_response_history_is_gone(response: axum_test::TestResponse) { assert_eq!(response.status_code(), 410); @@ -98,6 +99,115 @@ async fn stateless_responses_reject_persistent_fields() { } } +#[tokio::test] +async fn stateless_responses_reject_server_executed_tools_before_provider_work() { + // A mock keeps a regression local while its counter proves Responses did + // not start a server-side web-search request before returning the client + // error. The MCP URL uses the reserved `.invalid` TLD so it cannot point + // to a real third-party server if this validation ever regresses. + let web_search_provider = Arc::new(MockWebSearchProvider::default_results()); + let web_search_call_count = web_search_provider.call_count(); + let (server, _database, mock) = + setup_test_server_with_search_providers(web_search_provider, None).await; + let model = setup_qwen_model(&server).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let rejected_tools = [ + ("web_search", serde_json::json!({"type": "web_search"})), + ( + "web_context_search", + serde_json::json!({"type": "web_context_search"}), + ), + ("file_search", serde_json::json!({"type": "file_search"})), + ( + "code_interpreter", + serde_json::json!({"type": "code_interpreter"}), + ), + ("computer", serde_json::json!({"type": "computer"})), + ( + "mcp", + serde_json::json!({ + "type": "mcp", + "server_label": "test", + "server_url": "https://mcp.invalid/tools", + "require_approval": "never" + }), + ), + ]; + + for (tool_type, tool) in rejected_tools { + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model.clone(), + "input": "Use the configured tool.", + "store": false, + "stream": false, + "tools": [tool], + })) + .await; + + assert_eq!( + response.status_code(), + 400, + "{tool_type} must be rejected locally: {}", + response.text() + ); + let error = response.json::(); + assert_eq!( + error.error.r#type, "invalid_request_error", + "{tool_type} must have a stable client error envelope" + ); + assert!( + error.error.message.contains(tool_type), + "{tool_type} rejection should identify the unsupported tool: {}", + error.error.message + ); + assert!( + mock.last_chat_params().await.is_none(), + "{tool_type} must be rejected before inference" + ); + } + + assert_eq!( + web_search_call_count.load(Ordering::SeqCst), + 0, + "Responses must not invoke the retained Web Search provider" + ); +} + +#[tokio::test] +async fn stateless_responses_reject_mcp_approval_continuations_before_inference() { + let (server, _pool, mock, _database) = setup_test_server_with_pool().await; + let model = setup_qwen_model(&server).await; + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model, + "store": false, + "input": [{ + "type": "mcp_approval_response", + "approval_request_id": "mcpr_example", + "approve": true + }] + })) + .await; + + assert_eq!(response.status_code(), 400, "response: {}", response.text()); + let error = response.json::(); + assert_eq!(error.error.r#type, "invalid_request_error"); + assert!( + mock.last_chat_params().await.is_none(), + "MCP approval continuation must be rejected before inference" + ); +} + #[tokio::test] async fn completed_stateless_response_exposes_gateway_signature_when_persisted() { let server = setup_test_server().await; diff --git a/crates/api/tests/e2e_all/web_context_search.rs b/crates/api/tests/e2e_all/web_context_search.rs deleted file mode 100644 index 35f6c8da9..000000000 --- a/crates/api/tests/e2e_all/web_context_search.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! E2E coverage for the Responses API `web_context_search` tool. -//! -//! Uses mock inference and mock context-search providers so CI does not need Brave credentials. - -use crate::common::{ - get_api_key_for_org, mock_prompts, setup_org_with_credits, setup_qwen_model, - setup_test_server_with_search_providers, MockWebContextSearchProvider, MockWebSearchProvider, -}; -use inference_providers::mock::{RequestMatcher, ResponseTemplate, ToolCall}; -use serde_json::json; -use services::responses::tools::WebSearchResult; -use std::sync::Arc; - -#[tokio::test] -async fn test_non_streaming_web_context_search_with_mock_provider() { - let context_provider = Arc::new(MockWebContextSearchProvider::new(vec![WebSearchResult { - title: "NEAR Context Source".to_string(), - url: "https://example.com/near-context".to_string(), - snippet: "NEAR Protocol context from Brave LLM Context search.".to_string(), - }])); - let captured_context_params = context_provider.last_params(); - - let (server, _database, mock) = setup_test_server_with_search_providers( - Arc::new(MockWebSearchProvider::default_results()), - Some(context_provider), - ) - .await; - let model = setup_qwen_model(&server).await; - let org = setup_org_with_credits(&server, 10_000_000_000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - - let user_prompt = "Use context search to ground a short answer about NEAR Protocol."; - let expected_prompt = mock_prompts::build_prompt(user_prompt); - - mock.when(RequestMatcher::PromptWithTools { - prompt: expected_prompt, - tool_names: vec!["web_context_search".to_string()], - }) - .respond_with( - ResponseTemplate::new("").with_tool_calls(vec![ToolCall::new( - "web_context_search", - json!({ - "query": "NEAR Protocol", - "country": "US", - "search_lang": "en", - "freshness": "pw", - "spellcheck": false, - "count": 5, - "maximum_number_of_urls": 2, - "maximum_number_of_tokens": 2048, - "maximum_number_of_snippets": 4, - "maximum_number_of_tokens_per_url": 1024, - "maximum_number_of_snippets_per_url": 2, - "context_threshold_mode": "strict" - }) - .to_string(), - )]), - ) - .await; - - mock.set_default_response(ResponseTemplate::new( - "NEAR Protocol is a sharded layer-one blockchain with source-backed context [s:0]from Brave Context[/s:0].", - )) - .await; - - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "input": user_prompt, - "stream": false, - "tools": [ - { - "type": "web_context_search" - } - ] - })) - .await; - - assert_eq!( - response.status_code(), - 200, - "response failed: {}", - response.text() - ); - - let response_body = response.json::(); - assert_eq!(response_body["status"], "completed"); - - let output = response_body["output"] - .as_array() - .expect("response output should be an array"); - - let web_search_call = output - .iter() - .find(|item| item["type"] == "web_search_call") - .expect("web_context_search should emit a web_search_call output item"); - assert_eq!(web_search_call["status"], "completed"); - assert_eq!(web_search_call["action"]["type"], "search"); - assert_eq!(web_search_call["action"]["query"], "NEAR Protocol"); - - let final_output_text = output - .iter() - .rev() - .find(|item| item["type"] == "message") - .and_then(|message| message["content"].as_array()) - .and_then(|content| content.iter().find(|part| part["type"] == "output_text")) - .expect("final response should contain output_text"); - - let text = final_output_text["text"] - .as_str() - .expect("output_text should include text"); - assert!(text.contains("source-backed context")); - assert!( - !text.contains("[s:0]"), - "citation tags should be stripped from final text" - ); - - let annotations = final_output_text["annotations"] - .as_array() - .expect("output_text should include annotations"); - assert_eq!(annotations.len(), 1); - assert_eq!(annotations[0]["type"], "url_citation"); - assert_eq!(annotations[0]["title"], "NEAR Context Source"); - assert_eq!(annotations[0]["url"], "https://example.com/near-context"); - - let params = captured_context_params - .lock() - .expect("mock context search params lock poisoned") - .clone() - .expect("context provider should have been called"); - assert_eq!(params.query, "NEAR Protocol"); - assert_eq!(params.country.as_deref(), Some("US")); - assert_eq!(params.search_lang.as_deref(), Some("en")); - assert_eq!(params.freshness.as_deref(), Some("pw")); - assert_eq!(params.spellcheck, Some(false)); - assert_eq!(params.count, Some(5)); - assert_eq!(params.maximum_number_of_urls, Some(2)); - assert_eq!(params.maximum_number_of_tokens, Some(2048)); - assert_eq!(params.maximum_number_of_snippets, Some(4)); - assert_eq!(params.maximum_number_of_tokens_per_url, Some(1024)); - assert_eq!(params.maximum_number_of_snippets_per_url, Some(2)); - assert_eq!(params.context_threshold_mode.as_deref(), Some("strict")); -} diff --git a/crates/api/tests/e2e_all/web_search_citations.rs b/crates/api/tests/e2e_all/web_search_citations.rs deleted file mode 100644 index 73079e34a..000000000 --- a/crates/api/tests/e2e_all/web_search_citations.rs +++ /dev/null @@ -1,637 +0,0 @@ -// E2E tests for web search citation tracking -// Verifies that citations are properly parsed, indexed, and resolved to URLs -// Tests both streaming and non-streaming responses with web search enabled - -use crate::common::*; -use serde_json::json; - -/// Verify citation structure and validity -fn verify_citation_validity(annotation: &serde_json::Value, text: &str, citation_num: usize) { - println!("\n--- Citation {num} ---", num = citation_num + 1); - - // Check annotation type - let annotation_type = annotation - .get("type") - .and_then(|t| t.as_str()) - .expect("Citation: Type field should be present"); - assert_eq!( - annotation_type, "url_citation", - "Citation should be of type 'url_citation', got '{annotation_type}'" - ); - - // Get indices - let start_index = annotation - .get("start_index") - .and_then(|s| s.as_u64()) - .expect("Citation: start_index field should be present") as usize; - - let end_index = annotation - .get("end_index") - .and_then(|e| e.as_u64()) - .expect("Citation: end_index field should be present") as usize; - - // Get title and URL - let title = annotation - .get("title") - .and_then(|t| t.as_str()) - .expect("Citation: title field should be present"); - - let url = annotation - .get("url") - .and_then(|u| u.as_str()) - .expect("Citation: url field should be present"); - - // Verify indices are valid - assert!( - start_index < end_index, - "Citation #{n}: start_index ({s}) should be less than end_index ({e})", - n = citation_num + 1, - s = start_index, - e = end_index - ); - - assert!( - end_index <= text.len(), - "Citation #{n}: end_index ({e}) exceeds text length ({l}). Text: '{t}'", - n = citation_num + 1, - e = end_index, - l = text.len(), - t = text - ); - - // Extract cited text (convert character indices to actual characters, handling UTF-8 properly) - let cited_text: String = text - .chars() - .skip(start_index) - .take(end_index - start_index) - .collect(); - - println!(" Indices: [{start_index}, {end_index}]"); - println!(" Cited text: '{cited_text}'"); - println!(" Title: {title}"); - println!(" URL: {url}"); - - // Verify the cited text is not empty and meaningful - assert!( - !cited_text.trim().is_empty(), - "Citation #{n}: Cited text should not be empty", - n = citation_num + 1 - ); - - assert!( - cited_text.len() > 2, - "Citation #{n}: Cited text '{c}' is too short (must be > 2 characters)", - n = citation_num + 1, - c = cited_text - ); - - // Verify URL format - assert!( - url.starts_with("http://") || url.starts_with("https://"), - "Citation #{n}: URL '{u}' should start with http:// or https://", - n = citation_num + 1, - u = url - ); - - assert!( - url.len() > 10, - "Citation #{n}: URL '{u}' appears to be too short", - n = citation_num + 1, - u = url - ); - - // Verify title is not empty and reasonable length - assert!( - !title.is_empty(), - "Citation #{n}: Title should not be empty", - n = citation_num + 1 - ); - - assert!( - title.len() > 3, - "Citation #{n}: Title '{t}' is too short", - n = citation_num + 1, - t = title - ); - - println!(" ✓ Citation format valid"); -} - -#[tokio::test] -#[ignore] -async fn test_non_streaming_web_search_with_citations() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - let model = setup_glm_model(&server).await; - - // Create non-streaming response with web search - // Use a specific query that requires current information and citations - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "input": "What is the weather in San Francisco today? Search the web for current weather conditions.", - "store": false, - "stream": false, - "max_output_tokens": 512, - "temperature": 0.7, - "tools": [ - { - "type": "web_search" - } - ] - })) - .await; - - assert_eq!(response.status_code(), 200); - - let response_data = response.json::(); - - // Extract the final message - let output = response_data - .get("output") - .and_then(|v| v.as_array()) - .expect("Output should be an array"); - - let final_message = output - .iter() - .rev() - .find(|item| { - item.get("type") - .and_then(|t| t.as_str()) - .map(|t| t == "message") - .unwrap_or(false) - }) - .expect("Should have at least one message"); - - let content = final_message - .get("content") - .and_then(|c| c.as_array()) - .expect("Content should be an array"); - - let output_text = content - .iter() - .find(|item| { - item.get("type") - .and_then(|t| t.as_str()) - .map(|t| t == "output_text") - .unwrap_or(false) - }) - .expect("Should have output_text"); - - let text = output_text - .get("text") - .and_then(|t| t.as_str()) - .expect("Text should be present"); - - let annotations = output_text - .get("annotations") - .and_then(|a| a.as_array()) - .expect("Annotations should be present"); - - println!("\n=== Non-Streaming Response ==="); - println!("Text length: {} characters", text.len()); - let truncated_text = text.chars().take(300).collect::(); - println!("Text (first 300 chars): {truncated_text}"); - - println!("Annotations found: {count}", count = annotations.len()); - - // With real providers, web search with citations should produce citations - // However, with the mock provider in tests, citations may not be generated - // We verify the response structure is correct and any citations present are valid - if !annotations.is_empty() { - println!( - "✓ Found {count} citations in response", - count = annotations.len() - ); - - // Verify each citation has correct structure and valid indices - for (idx, annotation) in annotations.iter().enumerate() { - verify_citation_validity(annotation, text, idx); - } - - // Verify that citation indices don't overlap - let mut sorted_annotations: Vec<_> = annotations - .iter() - .enumerate() - .map(|(i, a)| { - let start = a.get("start_index").and_then(|s| s.as_u64()).unwrap() as usize; - let end = a.get("end_index").and_then(|e| e.as_u64()).unwrap() as usize; - (i, start, end) - }) - .collect(); - - sorted_annotations.sort_by_key(|a| a.1); - - println!("\n=== Citation Index Overlap Check ==="); - for window in sorted_annotations.windows(2) { - let (idx1, start1, end1) = window[0]; - let (idx2, start2, end2) = window[1]; - - println!("Citation {idx1} [{start1}-{end1}] vs Citation {idx2} [{start2}-{end2}]"); - - assert!( - end1 <= start2, - "Citations should not overlap: Citation {idx1} ends at {end1} but Citation {idx2} starts at {start2}" - ); - } - - println!( - "\n✅ Non-streaming test PASSED with {c} citations verified", - c = annotations.len() - ); - } else { - println!("ℹ No citations in mock provider response (expected for mock provider)"); - } -} - -#[tokio::test] -async fn test_streaming_web_search_with_citations() { - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - let model = setup_glm_model(&server).await; - - // Create streaming response with web search - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "input": "What is the current weather in New York City? Search the web for real-time weather conditions.", - "store": false, - "stream": true, - "max_output_tokens": 512, - "temperature": 0.7, - "tools": [ - { - "type": "web_search" - } - ] - })) - .await; - - assert_eq!(response.status_code(), 200); - - let response_text = response.text(); - - // Count streaming delta events to verify streaming is working - let delta_count = response_text - .lines() - .filter(|l| l.contains("response.output_text.delta")) - .count(); - - println!("✓ Received {delta_count} streaming delta events"); - - assert!( - delta_count > 5, - "Should have multiple delta events (token-by-token streaming)" - ); - - // Count real-time citation annotation events (NEW) - let annotation_event_lines: Vec<_> = response_text - .lines() - .filter(|l| l.contains("response.output_text.annotation.added")) - .collect(); - - let annotation_event_count = annotation_event_lines.len(); - println!("✓ Received {annotation_event_count} real-time citation annotation events"); - - // Parse the annotation events to collect their data - let mut streaming_annotations: Vec = Vec::new(); - for line in annotation_event_lines { - if let Some(json_str) = line.strip_prefix("data: ") { - if let Ok(event) = serde_json::from_str::(json_str) { - if let Some(annotation) = event.get("annotation") { - streaming_annotations.push(annotation.clone()); - } - } - } - } - - println!( - "✓ Parsed {count} annotation payloads from streaming events", - count = streaming_annotations.len() - ); - - // Extract the final message to check citations - let final_line = response_text - .lines() - .rfind(|l| l.contains("response.output_item.done")) - .and_then(|l| { - l.strip_prefix("data: ") - .and_then(|json_str| serde_json::from_str::(json_str).ok()) - }) - .expect("Should find completion event"); - - let output_text = final_line - .get("item") - .and_then(|item| item.get("content")) - .and_then(|c| c.as_array()) - .and_then(|content| { - content.iter().find(|item| { - item.get("type") - .and_then(|t| t.as_str()) - .map(|t| t == "output_text") - .unwrap_or(false) - }) - }) - .expect("Should have output_text"); - - let text = output_text - .get("text") - .and_then(|t| t.as_str()) - .expect("Text should be present"); - - let annotations = output_text - .get("annotations") - .and_then(|a| a.as_array()) - .expect("Annotations should be present"); - - println!("\n=== Streaming Response ==="); - println!("Text length: {} characters", text.len()); - let truncated_text = text.chars().take(300).collect::(); - println!("Text (first 300 chars): {truncated_text}"); - - println!( - "Annotations found in streaming: {count}", - count = annotations.len() - ); - - // With real providers, web search with citations should produce citations - // However, with the mock provider in tests, citations may not be generated - // We verify the response structure is correct and any citations present are valid - if !annotations.is_empty() { - println!( - "✓ Found {count} citations in streaming response", - count = annotations.len() - ); - - for (idx, annotation) in annotations.iter().enumerate() { - verify_citation_validity(annotation, text, idx); - } - } else { - println!("ℹ No citations in mock provider response (expected for mock provider)"); - } - - // Verify that streaming annotation events match final annotations - println!("\n=== Real-Time vs Final Annotation Comparison ==="); - println!( - "Streaming annotation events: {}", - streaming_annotations.len() - ); - println!("Final annotations: {}", annotations.len()); - - assert_eq!( - streaming_annotations.len(), - annotations.len(), - "Should receive one annotation event per citation. Got {s} streaming events but {f} final annotations", - s = streaming_annotations.len(), - f = annotations.len() - ); - - // Sort both by start_index for comparison - let mut sorted_streaming = streaming_annotations.clone(); - let mut sorted_final = annotations.clone(); - - sorted_streaming.sort_by_key(|a| a.get("start_index").and_then(|s| s.as_u64()).unwrap_or(0)); - - sorted_final.sort_by_key(|a| a.get("start_index").and_then(|s| s.as_u64()).unwrap_or(0)); - - // Compare each annotation - for (idx, (streaming, final_)) in sorted_streaming.iter().zip(sorted_final.iter()).enumerate() { - println!("\n Comparing annotation {}", idx + 1); - - // Compare type - let stream_type = streaming - .get("type") - .and_then(|t| t.as_str()) - .unwrap_or("missing"); - let final_type = final_ - .get("type") - .and_then(|t| t.as_str()) - .unwrap_or("missing"); - - assert_eq!( - stream_type, - final_type, - "Annotation {}: type mismatch - streaming={}, final={}", - idx + 1, - stream_type, - final_type - ); - - // Compare indices - let stream_start = streaming - .get("start_index") - .and_then(|s| s.as_u64()) - .unwrap_or(0); - let final_start = final_ - .get("start_index") - .and_then(|s| s.as_u64()) - .unwrap_or(0); - - assert_eq!( - stream_start, - final_start, - "Annotation {}: start_index mismatch - streaming={}, final={}", - idx + 1, - stream_start, - final_start - ); - - let stream_end = streaming - .get("end_index") - .and_then(|e| e.as_u64()) - .unwrap_or(0); - let final_end = final_ - .get("end_index") - .and_then(|e| e.as_u64()) - .unwrap_or(0); - - assert_eq!( - stream_end, - final_end, - "Annotation {}: end_index mismatch - streaming={}, final={}", - idx + 1, - stream_end, - final_end - ); - - // Compare URL - let stream_url = streaming.get("url").and_then(|u| u.as_str()).unwrap_or(""); - let final_url = final_.get("url").and_then(|u| u.as_str()).unwrap_or(""); - - assert_eq!( - stream_url, - final_url, - "Annotation {}: URL mismatch - streaming={}, final={}", - idx + 1, - stream_url, - final_url - ); - - // Compare title - let stream_title = streaming - .get("title") - .and_then(|t| t.as_str()) - .unwrap_or(""); - let final_title = final_.get("title").and_then(|t| t.as_str()).unwrap_or(""); - - assert_eq!( - stream_title, - final_title, - "Annotation {}: title mismatch - streaming={}, final={}", - idx + 1, - stream_title, - final_title - ); - - println!(" ✓ Annotation {} matches perfectly", idx + 1); - } - - // Verify that citation indices don't overlap - let mut sorted_annotations: Vec<_> = annotations - .iter() - .enumerate() - .map(|(i, a)| { - let start = a.get("start_index").and_then(|s| s.as_u64()).unwrap() as usize; - let end = a.get("end_index").and_then(|e| e.as_u64()).unwrap() as usize; - (i, start, end) - }) - .collect(); - - sorted_annotations.sort_by_key(|a| a.1); - - println!("\n=== Citation Index Overlap Check ==="); - for window in sorted_annotations.windows(2) { - let (idx1, start1, end1) = window[0]; - let (idx2, start2, end2) = window[1]; - - println!("Citation {idx1} [{start1}-{end1}] vs Citation {idx2} [{start2}-{end2}]"); - - assert!( - end1 <= start2, - "Citations should not overlap: Citation {idx1} ends at {end1} but Citation {idx2} starts at {start2}" - ); - } - - println!( - "\n✅ Streaming citation test PASSED with {c} citations verified", - c = annotations.len() - ); -} - -#[tokio::test] -async fn capture_streaming_citations_to_file() { - use std::fs::File; - use std::io::Write; - - let server = setup_test_server().await; - let org = setup_org_with_credits(&server, 10000000000i64).await; - let api_key = get_api_key_for_org(&server, org.id).await; - let model = setup_glm_model(&server).await; - - // Create streaming response with web search - let response = server - .post("/v1/responses") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&json!({ - "model": model, - "input": "What are the latest developments in AI? Search the web and provide current information with citations.", - "store": false, - "stream": true, - "max_output_tokens": 256, - "temperature": 0.7, - "tools": [ - { - "type": "web_search" - } - ] - })) - .await; - - assert_eq!(response.status_code(), 200); - - let response_text = response.text(); - - // Save to file - let mut file = - File::create("/tmp/streaming_citations_demo.sse").expect("Failed to create file"); - file.write_all(response_text.as_bytes()) - .expect("Failed to write file"); - - println!("\n✓ Saved streaming response to /tmp/streaming_citations_demo.sse"); - println!(" File size: {} bytes", response_text.len()); - - // Print statistics - let delta_count = response_text - .lines() - .filter(|l| l.contains("response.output_text.delta")) - .count(); - let annotation_count = response_text - .lines() - .filter(|l| l.contains("response.output_text.annotation.added")) - .count(); - let web_search_count = response_text - .lines() - .filter(|l| l.contains("web_search_call")) - .count(); - - println!("\n=== Event Statistics ==="); - println!("Text deltas: {delta_count}"); - println!("Citation annotations: {annotation_count}"); - println!("Web search events: {web_search_count}"); - - println!("\n=== Sample Events ==="); - - // Show first few deltas - println!("\nFirst 3 text deltas:"); - for line in response_text - .lines() - .filter(|l| l.contains("response.output_text.delta")) - .take(3) - { - if let Some(json_str) = line.strip_prefix("data: ") { - if let Ok(event) = serde_json::from_str::(json_str) { - println!( - " {}", - event.get("delta").and_then(|d| d.as_str()).unwrap_or("") - ); - } - } - } - - // Show citations - if annotation_count > 0 { - println!("\nCitation annotations found:"); - for line in response_text - .lines() - .filter(|l| l.contains("response.output_text.annotation.added")) - { - if let Some(json_str) = line.strip_prefix("data: ") { - if let Ok(event) = serde_json::from_str::(json_str) { - if let Some(annotation) = event.get("annotation") { - let title = annotation - .get("title") - .and_then(|t| t.as_str()) - .unwrap_or("N/A"); - let start = annotation - .get("start_index") - .and_then(|s| s.as_u64()) - .unwrap_or(0); - let end = annotation - .get("end_index") - .and_then(|e| e.as_u64()) - .unwrap_or(0); - println!(" [{start}, {end}] - {title}"); - } - } - } - } - } - - println!("\n✅ Captured streaming response successfully"); -} diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index 99b0968f1..394cc795c 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -64,6 +64,7 @@ pub struct CreateResponseRequest { #[serde(skip_serializing_if = "Option::is_none")] pub background: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option>)] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, @@ -330,6 +331,24 @@ pub enum ResponseTool { }, } +/// Public Responses tool schema. +/// +/// Runtime request parsing retains the legacy server-executed variants long +/// enough to return a clear 400 error, but the public Responses contract only +/// accepts client-managed custom functions. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type")] +pub enum ClientManagedResponseTool { + #[serde(rename = "function")] + Function { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + parameters: Option, + }, +} + /// User location for web search #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserLocation { @@ -461,6 +480,7 @@ pub struct ResponseObject { pub store: bool, pub temperature: f32, pub tool_choice: ResponseToolChoiceOutput, + #[schema(value_type = Vec)] pub tools: Vec, #[serde(default)] pub top_logprobs: i32, diff --git a/docs/local-development.md b/docs/local-development.md index 4a6c4bfe6..2832d3e7e 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -272,7 +272,8 @@ Provider refresh runs every 300s by default | `POST /v1/workspaces/{id}/api-keys` | session | Returns plaintext `key` — store it, it isn't shown again | | `GET /v1/models` | public | OpenAI-compatible model catalog with pricing metadata | | `POST /v1/chat/completions` | API key | OpenAI-compatible. Add `"stream": true` for SSE | -| `POST /v1/responses` | API key | Stateless `store: false` inference; response history is unavailable | +| `POST /v1/responses` | API key | Stateless `store: false` inference; client-managed function tools only | +| `POST /mcp` | API key | Independent MCP server exposing the `web_search` tool | | `GET /v1/attestation/report` | API key | TEE attestation (503 outside a CVM unless `DEV=true` in debug builds) | | `GET /v1/attestation/ita-token` | public | Intel Trust Authority JWT wrapper (requires ITA env vars) | | `GET /v1/signature/{chat_id}` | API key | Per-completion and `resp_*` signature lookup | @@ -308,9 +309,14 @@ the signature material contains no raw request or response content. A stream that disconnects before completion creates no `resp_*` attestation record or legacy disconnect fallback. -Stateful operations are rejected: Conversations, response history, file input -and file search, code interpreter, computer, and every MCP approval mode other -than `require_approval: "never"`. +Only custom `function` tools are supported by `POST /v1/responses`. +Server-executed tools are rejected locally: `web_search`, +`web_context_search`, `file_search`, `code_interpreter`, `computer`, and +remote `mcp` tools. This does not affect the separate `POST /mcp` MCP server, +which continues to expose its independent `web_search` tool. + +Stateful operations are also rejected: Conversations, response history, and +file input. ## 7. Troubleshooting From 8f06b2cf21a2d30cc8985d807f5e4761ba479a19 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:11:57 +0800 Subject: [PATCH 19/31] refactor(responses): reject image output models --- crates/api/src/lib.rs | 3 + crates/api/src/openapi.rs | 2 +- crates/services/src/responses/service.rs | 456 +---------------------- docs/local-development.md | 7 +- 4 files changed, 22 insertions(+), 446 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index ff9e5d449..dd3425003 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -2695,7 +2695,10 @@ mod tests { let description = responses_tag["description"] .as_str() .expect("Responses tag must document its contract"); + assert!(description + .contains("successful Responses inference makes exactly one Chat Completions call")); assert!(description.contains("Only custom `function` tools are supported")); + assert!(description.contains("image-generation/editing models are rejected")); assert!(description.contains("POST /mcp")); } diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index cbb9f18b4..f3bcfea6e 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -25,7 +25,7 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Only custom `function` tools are supported. They are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Server-executed tools (`web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`) are rejected. The separate `POST /mcp` endpoint continues to expose its `web_search` tool independently of Responses. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Conversations, response history, and file input are rejected."), + (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Every successful Responses inference makes exactly one Chat Completions call. Only custom `function` tools are supported. They are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Server-executed tools (`web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`) and image-generation/editing models are rejected. The separate `POST /mcp` endpoint continues to expose its `web_search` tool independently of Responses; use `/v1/images/*` for image generation/editing. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Conversations, response history, and file input are rejected."), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index 251ad1430..96fadcd20 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -104,6 +104,19 @@ impl ports::ResponseServiceTrait for ResponseServiceImpl { .validate() .and_then(|_| request.validate_stateless()) .map_err(errors::ResponseError::InvalidParams)?; + + // Responses is deliberately a compatibility layer over one Chat + // Completions request. Image generation/editing used to take a + // separate direct-provider path here; reject image-output models so + // they cannot silently bypass that contract. + if let Ok(Some(model)) = self.completion_service.get_model(&request.model).await { + if Self::has_image_generation_capability(&model.output_modalities) { + return Err(errors::ResponseError::InvalidParams( + "The stateless Responses API only supports a single /chat/completions request; image generation and image editing are not supported. Use /v1/images/generations or /v1/images/edits.".to_string(), + )); + } + } + let mut request = request; request.store = Some(false); request.background = Some(false); @@ -932,84 +945,6 @@ impl ResponseServiceImpl { let tools = tools::prepare_tools(&context.request); let tool_choice = tools::prepare_tool_choice(&context.request); - // Check if this is an image model and handle it specially - if let Ok(Some(model)) = context - .completion_service - .get_model(&context.request.model) - .await - { - if Self::has_image_generation_capability(&model.output_modalities) { - tracing::info!( - "Image generation model detected, handling image operation: {}", - model.model_name - ); - - // Handle image generation/editing and return early - let image_result = Self::process_image_operation( - &mut ctx, - &mut emitter, - &mut context, - &initial_response, - workspace_id_domain.clone(), - ) - .await; - - // Handle errors by updating response status to Failed - if let Err(e) = image_result { - let error_message = e.to_string(); - let failed_item = models::ResponseOutputItem::Message { - id: format!("msg_{}", Uuid::new_v4().simple()), - response_id: ctx.response_id_str.clone(), - previous_response_id: ctx.previous_response_id.clone(), - next_response_ids: vec![], - created_at: ctx.created_at, - status: models::ResponseItemStatus::Failed, - role: "assistant".to_string(), - content: vec![models::ResponseContentItem::OutputText { - text: error_message.clone(), - annotations: vec![], - logprobs: vec![], - }], - model: ctx.model.clone(), - metadata: None, - }; - if let Err(create_err) = context - .response_items_repository - .create( - ctx.response_id.clone(), - ctx.api_key_id, - ctx.conversation_id, - failed_item, - ) - .await - { - tracing::warn!( - "Failed to store failed image response item: {}", - create_err - ); - } - if let Err(update_err) = context - .response_repository - .update( - ctx.response_id.clone(), - workspace_id_domain.clone(), - None, - models::ResponseStatus::Failed, - None, - ) - .await - { - tracing::warn!( - "Failed to update response status to failed: {}", - update_err - ); - } - return Err(e); - } - return Ok(()); - } - } - // Responses is a stateless compatibility layer over exactly one Chat // Completions request. Client-defined functions are returned to the // caller; Cloud never executes them or starts another completion. @@ -2398,371 +2333,6 @@ impl ResponseServiceImpl { .map(|modalities| modalities.contains(&"image".to_string())) .unwrap_or(false) } - - /// Process image generation or editing operations - async fn process_image_operation( - ctx: &mut crate::responses::service_helpers::ResponseStreamContext, - emitter: &mut crate::responses::service_helpers::EventEmitter, - process_context: &mut ProcessStreamContext, - initial_response: &models::ResponseObject, - workspace_id_domain: crate::workspace::WorkspaceId, - ) -> Result<(), errors::ResponseError> { - // Extract text prompt from request - let prompt = Self::extract_prompt_from_request(&process_context.request)?; - - // Determine if this is image editing or generation - let (has_input_image, _has_input_text) = - Self::analyze_input_content(&process_context.request); - - // Build encryption headers - let mut extra_params = std::collections::HashMap::new(); - if let Some(model_pub_key) = &process_context.model_pub_key { - extra_params.insert( - crate::common::encryption_headers::MODEL_PUB_KEY.to_string(), - serde_json::json!(model_pub_key), - ); - } - - // Call the appropriate image API - let response = if has_input_image { - let image_bytes = - Self::extract_input_image_from_request(&process_context.request).await?; - let params = inference_providers::ImageEditParams { - model: process_context.request.model.clone(), - image: std::sync::Arc::new(image_bytes), - prompt, - size: None, - response_format: Some("b64_json".to_string()), - }; - - process_context - .completion_service - .get_inference_provider_pool() - .image_edit(params, process_context.body_hash.clone()) - .await - .map_err(|e| { - tracing::error!(error = %e, "Image edit request failed"); - errors::ResponseError::InternalError( - "Image edit processing failed. Please try again later.".to_string(), - ) - })? - } else { - let params = inference_providers::ImageGenerationParams { - model: process_context.request.model.clone(), - prompt, - size: None, - quality: None, - style: None, - n: Some(1), - response_format: Some("b64_json".to_string()), - extra: extra_params, - }; - - process_context - .completion_service - .get_inference_provider_pool() - .image_generation(params, process_context.body_hash.clone()) - .await - .map_err(|e| { - tracing::error!(error = %e, "Image generation request failed"); - errors::ResponseError::InternalError( - "Image generation processing failed. Please try again later.".to_string(), - ) - })? - }; - - // Extract base64 images from response - let image_data: Vec = response - .response - .data - .iter() - .filter_map(|img| { - img.b64_json.as_ref().map(|b64| models::ImageOutputData { - b64_json: Some(b64.clone()), - url: None, - revised_prompt: None, - }) - }) - .collect(); - - if image_data.is_empty() { - return Err(errors::ResponseError::InternalError( - "Image generation returned no results".to_string(), - )); - } - - // Create message item with image output - let message_item = models::ResponseOutputItem::Message { - id: format!("msg_{}", Uuid::new_v4().simple()), - response_id: ctx.response_id_str.clone(), - previous_response_id: ctx.previous_response_id.clone(), - next_response_ids: vec![], - created_at: ctx.created_at, - status: models::ResponseItemStatus::Completed, - role: "assistant".to_string(), - content: vec![models::ResponseContentItem::OutputImage { - data: image_data.clone(), - url: None, - }], - model: process_context.request.model.clone(), - metadata: None, - }; - - // Store the image message item in database - process_context - .response_items_repository - .create( - ctx.response_id.clone(), - ctx.api_key_id, - ctx.conversation_id, - message_item.clone(), - ) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!( - "Failed to store image response item: {e}" - )) - })?; - - // Emit image output item event - let event = models::ResponseStreamEvent { - event_type: "response.output_item.added".to_string(), - sequence_number: None, - response: None, - output_index: Some(0), - content_index: None, - item: Some(message_item.clone()), - item_id: None, - part: None, - delta: None, - text: None, - error: None, - status_code: None, - logprobs: None, - obfuscation: None, - annotation_index: None, - annotation: None, - conversation_title: None, - usage: None, - }; - use futures_util::SinkExt; - let _ = emitter.tx.clone().send(event).await; - - // Update response status and usage - let mut final_response = initial_response.clone(); - final_response.status = models::ResponseStatus::Completed; - // For image operations, report image count as output tokens - final_response.usage = models::Usage::new(0, image_data.len() as i32); - // Include the message item with image in the output - final_response.output = vec![message_item.clone()]; - - // Emit completion event - let completion_event = models::ResponseStreamEvent { - event_type: "response.completed".to_string(), - sequence_number: None, - response: Some(final_response.clone()), - output_index: None, - content_index: None, - item: None, - item_id: None, - part: None, - delta: None, - text: None, - error: None, - status_code: None, - logprobs: None, - obfuscation: None, - annotation_index: None, - annotation: None, - conversation_title: None, - usage: Some(final_response.usage.clone()), - }; - let _ = emitter.tx.clone().send(completion_event).await; - - // Update response in database - let usage_json = serde_json::to_value(&final_response.usage).map_err(|e| { - errors::ResponseError::InternalError(format!("Failed to serialize usage: {e}")) - })?; - - process_context - .response_repository - .update( - ctx.response_id.clone(), - workspace_id_domain, - None, - final_response.status.clone(), - Some(usage_json), - ) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!( - "Failed to update response with image usage: {e}" - )) - })?; - - ctx.total_output_tokens += image_data.len() as i32; - - Ok(()) - } - - /// Analyze input content to determine if it contains images or text - fn analyze_input_content(request: &models::CreateResponseRequest) -> (bool, bool) { - let mut has_image = false; - let mut has_text = false; - - if let Some(models::ResponseInput::Text(text)) = &request.input { - has_text = !text.trim().is_empty(); - } else if let Some(models::ResponseInput::Items(items)) = &request.input { - for item in items { - match item.content() { - Some(models::ResponseContent::Text(text)) => { - if !text.trim().is_empty() { - has_text = true; - } - } - Some(models::ResponseContent::Parts(parts)) => { - for part in parts { - match part { - models::ResponseContentPart::InputImage { .. } => { - has_image = true; - } - models::ResponseContentPart::InputText { text } => { - if !text.trim().is_empty() { - has_text = true; - } - } - models::ResponseContentPart::InputFile { .. } => { - has_text = true; - } - } - } - } - None => {} - } - } - } - - (has_image, has_text) - } - - /// Validate image format by checking magic bytes - fn validate_image_format(data: &[u8]) -> Result<(), errors::ResponseError> { - if data.len() < 3 { - return Err(errors::ResponseError::InvalidParams( - "Image data too small".to_string(), - )); - } - - // Check for JPEG (FF D8 FF) or PNG (89 50 4E 47) - if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) - || (data.len() >= 4 - && data[0] == 0x89 - && data[1] == 0x50 - && data[2] == 0x4E - && data[3] == 0x47) - { - Ok(()) - } else { - Err(errors::ResponseError::InvalidParams( - "Invalid image format (must be PNG or JPEG)".to_string(), - )) - } - } - - /// Extract text prompt from request - fn extract_prompt_from_request( - request: &models::CreateResponseRequest, - ) -> Result { - match &request.input { - Some(models::ResponseInput::Text(text)) => Ok(text.clone()), - Some(models::ResponseInput::Items(items)) => { - let mut text_parts = Vec::new(); - for item in items { - match item.content() { - Some(models::ResponseContent::Text(text)) => { - text_parts.push(text.clone()); - } - Some(models::ResponseContent::Parts(parts)) => { - for part in parts { - if let models::ResponseContentPart::InputText { text } = part { - text_parts.push(text.clone()); - } - } - } - None => {} - } - } - if text_parts.is_empty() { - return Err(errors::ResponseError::InvalidParams( - "No text prompt found".to_string(), - )); - } - Ok(text_parts.join(" ")) - } - None => Err(errors::ResponseError::InvalidParams( - "No input provided".to_string(), - )), - } - } - - /// Extract image from request (for edit operations) - async fn extract_input_image_from_request( - request: &models::CreateResponseRequest, - ) -> Result, errors::ResponseError> { - use base64::Engine; - - if let Some(models::ResponseInput::Items(items)) = &request.input { - for item in items { - if let Some(models::ResponseContent::Parts(parts)) = item.content() { - for part in parts { - if let models::ResponseContentPart::InputImage { - image_url, - detail: _, - } = part - { - let url_str = match image_url { - models::ResponseImageUrl::String(s) => s.clone(), - models::ResponseImageUrl::Object { url } => url.clone(), - }; - - if let Some(comma_pos) = url_str.find(',') { - let base64_str = &url_str[comma_pos + 1..]; - let base64_str_owned = base64_str.to_string(); - - let decoded = tokio::task::spawn_blocking(move || { - base64::engine::general_purpose::STANDARD - .decode(&base64_str_owned) - }) - .await - .map_err(|e| { - errors::ResponseError::InternalError(format!( - "Base64 decode failed: {e}" - )) - })? - .map_err(|e| { - errors::ResponseError::InvalidParams(format!( - "Failed to decode base64: {e}" - )) - })?; - - Self::validate_image_format(&decoded)?; - return Ok(decoded); - } else { - // Found an image but it's not in data URL format - return Err(errors::ResponseError::InvalidParams( - "Unsupported image URL format: expected data URL with base64 encoding (e.g., 'data:image/png;base64,...')".to_string(), - )); - } - } - } - } - } - } - - Err(errors::ResponseError::InvalidParams( - "No input image found".to_string(), - )) - } } #[cfg(test)] diff --git a/docs/local-development.md b/docs/local-development.md index 2832d3e7e..6e62809ee 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -312,8 +312,11 @@ legacy disconnect fallback. Only custom `function` tools are supported by `POST /v1/responses`. Server-executed tools are rejected locally: `web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and -remote `mcp` tools. This does not affect the separate `POST /mcp` MCP server, -which continues to expose its independent `web_search` tool. +remote `mcp` tools. Image-generation and image-editing models are also +rejected so every successful Responses inference makes exactly one Chat +Completions call. This does not affect the separate `POST /mcp` MCP server, +which continues to expose its independent `web_search` tool, or the +`/v1/images/*` endpoints. Stateful operations are also rejected: Conversations, response history, and file input. From d9ff5b7bd3dae4d5a1ab8a363d128a0531244fd1 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:11:43 +0800 Subject: [PATCH 20/31] test(responses): reject image output models before inference --- .../api/tests/e2e_all/responses_stateless.rs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/api/tests/e2e_all/responses_stateless.rs b/crates/api/tests/e2e_all/responses_stateless.rs index 5f36a787f..20917c117 100644 --- a/crates/api/tests/e2e_all/responses_stateless.rs +++ b/crates/api/tests/e2e_all/responses_stateless.rs @@ -1,6 +1,7 @@ //! E2E boundary coverage for the stateless Responses API. use crate::common::*; +use api::models::BatchUpdateModelApiRequest; use axum::http::Method; use std::sync::{atomic::Ordering, Arc}; @@ -178,6 +179,82 @@ async fn stateless_responses_reject_server_executed_tools_before_provider_work() ); } +#[tokio::test] +async fn stateless_responses_reject_image_output_models_before_provider_work() { + let (server, _pool, mock, _database) = setup_test_server_with_pool().await; + + // Configure the capability explicitly instead of relying on the catalog + // defaults for the model name. Responses must remain a text-completion + // wrapper and reject image-generation models before asking a provider to + // do any work. + let model = "Qwen/Qwen-Image-2512"; + let mut batch = BatchUpdateModelApiRequest::new(); + batch.insert( + model.to_string(), + serde_json::from_value(serde_json::json!({ + "inputCostPerToken": { "amount": 0, "currency": "USD" }, + "outputCostPerToken": { "amount": 0, "currency": "USD" }, + "costPerImage": { "amount": 40000000, "currency": "USD" }, + "modelDisplayName": "Responses Image Output Test Model", + "modelDescription": "Active image-output model for Responses validation", + "contextLength": 4096, + "maxOutputLength": 1024, + "verifiable": true, + "isActive": true, + "inputModalities": ["text"], + "outputModalities": ["image"] + })) + .expect("image-output model configuration must be valid"), + ); + let updated = admin_batch_upsert_models(&server, batch, get_session_id()).await; + assert_eq!(updated.len(), 1, "image-output model must be active"); + assert_eq!( + updated[0] + .metadata + .architecture + .as_ref() + .expect("model architecture must be returned") + .output_modalities, + vec!["image".to_string()], + "test model must advertise image output" + ); + // Keep this in step with the existing model setup helpers: the test + // server's model registry updates asynchronously after an admin upsert. + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + let org = setup_org_with_credits(&server, 10_000_000_000i64).await; + let api_key = get_api_key_for_org(&server, org.id).await; + + let response = server + .post("/v1/responses") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model, + "input": "Draw a small red square.", + "store": false, + "stream": false, + })) + .await; + + assert_eq!( + response.status_code(), + 400, + "image-output models must be rejected before inference: {}", + response.text() + ); + let error = response.json::(); + assert_eq!(error.error.r#type, "invalid_request_error"); + assert!( + error.error.message.to_ascii_lowercase().contains("image"), + "the client error must explain the unsupported image output: {}", + error.error.message + ); + assert!( + mock.last_chat_params().await.is_none(), + "an image-output Responses request must be rejected before chat completion" + ); +} + #[tokio::test] async fn stateless_responses_reject_mcp_approval_continuations_before_inference() { let (server, _pool, mock, _database) = setup_test_server_with_pool().await; From 2d23e656b8732db59a92385a6ae67ca3ef29800c Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:13:48 +0800 Subject: [PATCH 21/31] fix(responses): remove unused stream context mutability --- crates/services/src/responses/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index 96fadcd20..7e8823b3c 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -853,7 +853,7 @@ impl ResponseServiceImpl { /// Process the response stream - main logic async fn process_response_stream( tx: futures::channel::mpsc::UnboundedSender, - mut context: ProcessStreamContext, + context: ProcessStreamContext, usage_tracker: Arc, ) -> Result<(), errors::ResponseError> { tracing::info!("Starting response stream processing"); From 9c3db6b2fca9735e811fdab18354132cafdb6f8a Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:29:30 +0800 Subject: [PATCH 22/31] fix(responses): reject builtin tools during parsing --- .../api/tests/e2e_all/responses_stateless.rs | 19 +++-- crates/services/src/responses/models.rs | 79 ++++++++++++++++++- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/crates/api/tests/e2e_all/responses_stateless.rs b/crates/api/tests/e2e_all/responses_stateless.rs index 20917c117..fb2000f7f 100644 --- a/crates/api/tests/e2e_all/responses_stateless.rs +++ b/crates/api/tests/e2e_all/responses_stateless.rs @@ -101,11 +101,11 @@ async fn stateless_responses_reject_persistent_fields() { } #[tokio::test] -async fn stateless_responses_reject_server_executed_tools_before_provider_work() { - // A mock keeps a regression local while its counter proves Responses did - // not start a server-side web-search request before returning the client - // error. The MCP URL uses the reserved `.invalid` TLD so it cannot point - // to a real third-party server if this validation ever regresses. +async fn stateless_responses_reject_unsupported_tool_types_during_deserialization() { + // The request enum must reject builtin types before the handler reaches + // service validation or provider work. A mock makes that boundary + // observable. The MCP URL uses the reserved `.invalid` TLD so it cannot + // point to a real third party if this behavior ever regresses. let web_search_provider = Arc::new(MockWebSearchProvider::default_results()); let web_search_call_count = web_search_provider.call_count(); let (server, _database, mock) = @@ -163,12 +163,17 @@ async fn stateless_responses_reject_server_executed_tools_before_provider_work() ); assert!( error.error.message.contains(tool_type), - "{tool_type} rejection should identify the unsupported tool: {}", + "deserialization rejection should identify {tool_type}: {}", + error.error.message + ); + assert!( + error.error.message.contains("function"), + "the function-only tool enum should reject {tool_type}: {}", error.error.message ); assert!( mock.last_chat_params().await.is_none(), - "{tool_type} must be rejected before inference" + "{tool_type} must be rejected before service/provider work" ); } diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index 394cc795c..815f96029 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -65,6 +65,10 @@ pub struct CreateResponseRequest { pub background: Option, #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Option>)] + #[serde( + default, + deserialize_with = "deserialize_client_managed_response_tools" + )] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, @@ -331,11 +335,12 @@ pub enum ResponseTool { }, } -/// Public Responses tool schema. +/// The only tool shape accepted from a public Responses request. /// -/// Runtime request parsing retains the legacy server-executed variants long -/// enough to return a clear 400 error, but the public Responses contract only -/// accepts client-managed custom functions. +/// `ResponseTool` retains legacy variants for dormant/internal code paths, but +/// the `CreateResponseRequest::tools` deserializer first parses this enum. That +/// makes unsupported builtin types ordinary request-deserialization failures, +/// before the route or a provider can do any work. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(tag = "type")] pub enum ClientManagedResponseTool { @@ -349,6 +354,32 @@ pub enum ClientManagedResponseTool { }, } +impl From for ResponseTool { + fn from(tool: ClientManagedResponseTool) -> Self { + match tool { + ClientManagedResponseTool::Function { + name, + description, + parameters, + } => Self::Function { + name, + description, + parameters, + }, + } + } +} + +fn deserialize_client_managed_response_tools<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::>::deserialize(deserializer) + .map(|tools| tools.map(|tools| tools.into_iter().map(ResponseTool::from).collect())) +} + /// User location for web search #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserLocation { @@ -1372,6 +1403,9 @@ impl CreateResponseRequest { } } + // The HTTP parser only admits `ClientManagedResponseTool`, but retain + // this defense for programmatic callers that construct the legacy + // internal `ResponseTool` enum directly. if let Some(tools) = &self.tools { for tool in tools { match tool { @@ -2457,4 +2491,41 @@ mod tests { .unwrap_err() .contains("web_search is not supported")); } + + #[test] + fn response_request_deserialization_accepts_only_function_tools() { + let request: CreateResponseRequest = serde_json::from_value(json!({ + "model": "gpt-4", + "tools": [{ + "type": "function", + "name": "web_search", + "parameters": {"type": "object"} + }] + })) + .expect("custom functions must deserialize"); + assert!(matches!( + request.tools.as_deref(), + Some([ResponseTool::Function { name, .. }]) if name == "web_search" + )); + + for unsupported_type in [ + "web_search", + "web_context_search", + "file_search", + "code_interpreter", + "computer", + "mcp", + ] { + let error = serde_json::from_value::(json!({ + "model": "gpt-4", + "tools": [{"type": unsupported_type}] + })) + .expect_err("{unsupported_type} must fail request deserialization"); + + assert!( + error.to_string().contains(unsupported_type), + "deserialization error should identify {unsupported_type}: {error}" + ); + } + } } From 8f4e51f8b445badb985eb5893ccc051e55c6f397 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:42:24 +0800 Subject: [PATCH 23/31] fix(api): keep confidential data views read-only --- crates/api/src/lib.rs | 311 +++++++++++++++---------- crates/api/src/routes/conversations.rs | 28 ++- crates/api/src/routes/files.rs | 24 +- 3 files changed, 225 insertions(+), 138 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index dd3425003..a5e6380f2 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -43,7 +43,7 @@ use axum::{ extract::DefaultBodyLimit, middleware::{from_fn, from_fn_with_state, map_response, Next}, response::Html, - routing::{any, get, post}, + routing::{any, get, post, MethodRouter}, Router, }; use config::ApiConfig; @@ -1239,7 +1239,10 @@ pub fn build_app_with_config( rate_limit_state.clone(), ); - let conversation_routes = build_conversation_routes(&auth_components.auth_state_middleware); + let conversation_routes = build_conversation_routes( + domain_services.conversation_service.clone(), + &auth_components.auth_state_middleware, + ); let management_routes = build_management_router( app_state.clone(), @@ -1739,26 +1742,62 @@ pub fn build_mcp_routes( )) } -/// Build retired Conversation API routes. +/// Build the temporary read-only Conversation API surface. /// -/// Conversation data remains in place for now, but the public API no longer -/// permits reading or writing it. Keep the route catch-all so callers receive -/// a stable migration response rather than a generic 404 or 405. -pub fn build_conversation_routes(auth_state_middleware: &AuthState) -> Router { - build_retired_conversation_routes().layer(from_fn_with_state( +/// Existing conversation data remains available through its original view +/// routes so chat-api can export it. All mutations and unsupported legacy +/// paths stay authenticated and return 410 until the data-retention migration +/// is complete. +pub fn build_conversation_routes( + conversation_service: Arc, + auth_state_middleware: &AuthState, +) -> Router { + build_read_only_conversation_route_layout( + post(conversations::batch_get_conversations) + .fallback(conversations::conversation_write_disabled), + get(conversations::get_conversation).fallback(conversations::conversation_write_disabled), + get(conversations::list_conversation_items) + .fallback(conversations::conversation_write_disabled), + conversations::conversation_write_disabled, + ) + .with_state( + conversation_service as Arc, + ) + .layer(from_fn_with_state( auth_state_middleware.clone(), auth_middleware_with_api_key, )) + // Conversation records can contain confidential prompt and completion + // data. Apply this outside auth so rejects are no-store as well. + .layer(map_response(no_store_response)) } -fn build_retired_conversation_routes() -> Router { +/// Install the exact temporary Conversation API route layout. +/// +/// Kept separate from service wiring so route precedence can be tested without +/// a database; production uses this helper directly. The per-route fallbacks +/// make unsupported methods 410, while the nested fallback handles unknown +/// legacy descendants without conflicting with parameter routes. +fn build_read_only_conversation_route_layout( + batch: MethodRouter, + conversation: MethodRouter, + items: MethodRouter, + write_disabled: H, +) -> Router +where + S: Clone + Send + Sync + 'static, + H: axum::handler::Handler, + T: 'static, +{ + let descendants = Router::new() + .route("/batch", batch) + .route("/{conversation_id}", conversation) + .route("/{conversation_id}/items", items) + .fallback(write_disabled.clone()); + Router::new() - .route("/conversations", any(conversations::conversation_api_gone)) - .route("/conversations/", any(conversations::conversation_api_gone)) - .route( - "/conversations/{*path}", - any(conversations::conversation_api_gone), - ) + .route("/conversations", any(write_disabled)) + .nest("/conversations/", descendants) } /// Build attestation routes with auth. @@ -1830,23 +1869,55 @@ pub fn build_workspace_routes(app_state: AppState, auth_state_middleware: &AuthS )) } -/// Build the retired Files API surface. +/// Build the temporary read-only Files API surface. /// -/// Keep the authenticated route boundary while preventing every Files request -/// from reaching the legacy service, storage, or repository layers. The -/// dormant wiring is intentionally retained until the follow-up cleanup. +/// Existing metadata and content stay available through the original GET +/// routes so chat-api can export data. Uploads, deletion, and unsupported +/// legacy paths remain authenticated 410 responses and never reach storage. pub fn build_files_routes(app_state: AppState, auth_state_middleware: &AuthState) -> Router { - use crate::routes::files::files_api_deprecated; + use crate::routes::files::{files_write_disabled, get_file, get_file_content, list_files}; + + build_read_only_file_route_layout( + get(list_files).fallback(files_write_disabled), + get(get_file).fallback(files_write_disabled), + get(get_file_content).fallback(files_write_disabled), + files_write_disabled, + ) + .with_state(app_state) + .layer(from_fn_with_state( + auth_state_middleware.clone(), + auth_middleware_with_api_key, + )) + // File metadata and content are confidential. Keep both the temporary + // view routes and their 410 responses out of shared/browser caches. + .layer(map_response(no_store_response)) +} + +/// Install the exact temporary Files API route layout. +/// +/// Like Conversations, this is shared by production and route-precedence +/// tests. Only the original GET views receive service handlers; other methods +/// and descendants land on the authenticated 410 router through a scoped +/// nested fallback. +fn build_read_only_file_route_layout( + list: MethodRouter, + file: MethodRouter, + content: MethodRouter, + write_disabled: H, +) -> Router +where + S: Clone + Send + Sync + 'static, + H: axum::handler::Handler, + T: 'static, +{ + let descendants = Router::new() + .route("/{file_id}/content", content) + .route("/{file_id}", file) + .fallback(write_disabled); Router::new() - .route("/files", axum::routing::any(files_api_deprecated)) - .route("/files/", axum::routing::any(files_api_deprecated)) - .route("/files/{*path}", axum::routing::any(files_api_deprecated)) - .with_state(app_state) - .layer(from_fn_with_state( - auth_state_middleware.clone(), - auth_middleware_with_api_key, - )) + .route("/files", list) + .nest("/files/", descendants) } /// Build feature request routes for user submissions and admin aggregation. @@ -2041,8 +2112,8 @@ async fn cache_control_on_success( res } -/// Force every response for a request-scoped Responses route to bypass shared -/// and browser caches. Unlike public catalog routes, even error responses may +/// Force every response for a confidential-data route to bypass shared and +/// browser caches. Unlike public catalog routes, even error responses may /// include request-specific details from validation or authentication. async fn no_store_response(mut response: Response) -> Response { response @@ -2719,38 +2790,6 @@ mod tests { ); } - #[test] - fn test_openapi_excludes_retired_conversation_api() { - let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); - let paths = spec["paths"].as_object().unwrap(); - assert!( - paths - .keys() - .all(|path| !path.starts_with("/v1/conversations")), - "OpenAPI must not advertise retired Conversation routes" - ); - - let tags = spec["tags"].as_array().unwrap(); - assert!( - tags.iter().all(|tag| tag["name"] != "Conversations"), - "OpenAPI must not advertise the Conversations tag" - ); - - let schemas = spec["components"]["schemas"].as_object().unwrap(); - for schema in [ - "CreateConversationRequest", - "ConversationObject", - "UpdateConversationRequest", - "ConversationDeleteResult", - "ConversationItemList", - ] { - assert!( - !schemas.contains_key(schema), - "OpenAPI must not expose retired Conversation schema {schema}" - ); - } - } - #[test] fn test_openapi_admin_aml_paths_require_session_security() { let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); @@ -2788,81 +2827,117 @@ mod tests { } #[tokio::test] - async fn conversation_routes_return_gone_for_every_retired_surface() { - let app = Router::new().nest("/v1", build_retired_conversation_routes()); - let routes = [ - (axum::http::Method::POST, "/v1/conversations"), - (axum::http::Method::GET, "/v1/conversations/"), - (axum::http::Method::POST, "/v1/conversations/batch"), - (axum::http::Method::GET, "/v1/conversations/conv_example"), - (axum::http::Method::POST, "/v1/conversations/conv_example"), - (axum::http::Method::DELETE, "/v1/conversations/conv_example"), - ( - axum::http::Method::POST, - "/v1/conversations/conv_example/pin", - ), - ( - axum::http::Method::DELETE, - "/v1/conversations/conv_example/pin", - ), - ( - axum::http::Method::POST, - "/v1/conversations/conv_example/archive", - ), - ( - axum::http::Method::DELETE, - "/v1/conversations/conv_example/archive", - ), - ( - axum::http::Method::POST, - "/v1/conversations/conv_example/clone", - ), - ( - axum::http::Method::GET, - "/v1/conversations/conv_example/items", - ), - ( - axum::http::Method::POST, - "/v1/conversations/conv_example/items", - ), - // The catch-all also keeps unknown legacy subpaths from becoming - // misleading 404 or 405 responses. - ( - axum::http::Method::PATCH, - "/v1/conversations/conv_example/unknown", - ), - ]; + async fn read_only_data_route_layout_preserves_views_and_rejects_mutations() { + async fn view() -> StatusCode { + StatusCode::OK + } - for (method, path) in routes { + async fn write_disabled() -> StatusCode { + StatusCode::GONE + } + + async fn unrelated_route() -> StatusCode { + StatusCode::NOT_FOUND + } + + // These are the production route-layout helpers. Using lightweight + // handlers here isolates Axum's static/parameter/catch-all matching + // from database state while proving the exact public layout builds. + let conversation_routes = build_read_only_conversation_route_layout( + axum::routing::post(view).fallback(write_disabled), + axum::routing::get(view).fallback(write_disabled), + axum::routing::get(view).fallback(write_disabled), + write_disabled, + ) + .layer(map_response(no_store_response)); + let file_routes = build_read_only_file_route_layout( + axum::routing::get(view).fallback(write_disabled), + axum::routing::get(view).fallback(write_disabled), + axum::routing::get(view).fallback(write_disabled), + write_disabled, + ) + .layer(map_response(no_store_response)); + let app = Router::new() + .merge(conversation_routes) + .merge(file_routes) + .fallback(unrelated_route); + + for (method, path) in [ + ("POST", "/conversations/batch"), + ("GET", "/conversations/conv_example"), + ("GET", "/conversations/conv_example/items"), + ("GET", "/files"), + ("GET", "/files/file_example"), + ("GET", "/files/file_example/content"), + ] { let response = app .clone() .oneshot( HttpRequest::builder() - .method(method.clone()) + .method(method) .uri(path) .body(Body::empty()) .unwrap(), ) .await .unwrap(); - + assert_eq!(response.status(), StatusCode::OK, "{method} {path}"); assert_eq!( - response.status(), - StatusCode::GONE, - "{method} {path} must return 410 Gone" + response + .headers() + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{method} {path}" ); + } - let body = axum::body::to_bytes(response.into_body(), usize::MAX) + for (method, path) in [ + ("POST", "/conversations"), + ("GET", "/conversations/"), + ("GET", "/conversations/batch"), + ("POST", "/conversations/conv_example"), + ("POST", "/conversations/conv_example/items"), + ("PATCH", "/conversations/conv_example/unknown"), + ("POST", "/files"), + ("GET", "/files/"), + ("DELETE", "/files/file_example"), + ("PUT", "/files/legacy/nested/path"), + ] { + let response = app + .clone() + .oneshot( + HttpRequest::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); - let error: crate::models::ErrorResponse = serde_json::from_slice(&body).unwrap(); - assert_eq!(error.error.r#type, "gone"); + assert_eq!(response.status(), StatusCode::GONE, "{method} {path}"); assert_eq!( - error.error.code.as_deref(), - Some("conversation_api_retired") + response + .headers() + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{method} {path}" ); - assert!(error.error.message.contains("POST /v1/responses")); } + + let unrelated = app + .oneshot( + HttpRequest::builder() + .method("GET") + .uri("/unrelated") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unrelated.status(), StatusCode::NOT_FOUND); + assert!(unrelated.headers().get(CACHE_CONTROL).is_none()); } /// Example of how to set up the application for E2E testing #[tokio::test] diff --git a/crates/api/src/routes/conversations.rs b/crates/api/src/routes/conversations.rs index 82b3e6cd1..65cdd7dba 100644 --- a/crates/api/src/routes/conversations.rs +++ b/crates/api/src/routes/conversations.rs @@ -14,22 +14,22 @@ use std::sync::Arc; use tracing::debug; use uuid::Uuid; -const CONVERSATIONS_API_RETIRED_MESSAGE: &str = "The Conversations API has been deprecated and is no longer available. Use stateless POST /v1/responses with store: false and include any prior conversation history in the request."; +const CONVERSATION_WRITE_DISABLED_MESSAGE: &str = "The Conversations API is temporarily read-only while existing data remains available for export. This operation is no longer available. Only POST /v1/conversations/batch, GET /v1/conversations/{conversation_id}, and GET /v1/conversations/{conversation_id}/items are supported."; -/// Return a stable migration response for every retired Conversation API route. +/// Return a stable migration response for Conversation mutations. /// -/// API-key authentication is enforced by the router. This handler intentionally -/// has no service dependencies, so retired requests cannot read or write -/// existing conversation data. -pub async fn conversation_api_gone() -> (StatusCode, ResponseJson) { +/// API-key authentication is enforced by the router. The read-only routes use +/// the same workspace-scoped service as before; this handler is only attached +/// to writes and unsupported legacy paths. +pub async fn conversation_write_disabled() -> (StatusCode, ResponseJson) { ( StatusCode::GONE, ResponseJson(ErrorResponse { error: ErrorDetail { - message: CONVERSATIONS_API_RETIRED_MESSAGE.to_string(), + message: CONVERSATION_WRITE_DISABLED_MESSAGE.to_string(), r#type: "gone".to_string(), param: None, - code: Some("conversation_api_retired".to_string()), + code: Some("conversation_write_disabled".to_string()), }, }), ) @@ -140,7 +140,9 @@ pub async fn create_conversation( tag = "Conversations", request_body = BatchConversationsRequest, responses( - (status = 200, description = "Conversations retrieved", body = ConversationBatchResponse), + (status = 200, description = "Conversations retrieved", body = ConversationBatchResponse, + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request (empty IDs or invalid format)", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) @@ -271,7 +273,9 @@ pub async fn batch_get_conversations( ("conversation_id" = String, Path, description = "Conversation ID") ), responses( - (status = 200, description = "Conversation details", body = ConversationObject), + (status = 200, description = "Conversation details", body = ConversationObject, + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Conversation not found", body = ErrorResponse), @@ -843,7 +847,9 @@ pub async fn clone_conversation( ("offset" = Option, Query, description = "Number of items to skip") ), responses( - (status = 200, description = "List of conversation items", body = ConversationItemList), + (status = 200, description = "List of conversation items", body = ConversationItemList, + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Conversation not found", body = ErrorResponse), diff --git a/crates/api/src/routes/files.rs b/crates/api/src/routes/files.rs index a40095a81..d7642cb81 100644 --- a/crates/api/src/routes/files.rs +++ b/crates/api/src/routes/files.rs @@ -15,16 +15,16 @@ use uuid::Uuid; pub const MAX_FILE_SIZE: usize = 512 * 1024 * 1024; // 512 MB -/// Returns the documented retirement response for the former Files API. +/// Return a stable migration response for Files mutations. /// -/// The service and its stored data remain in place for now so existing data can -/// be handled by a separate retention effort, but no Files API request may -/// trigger a new upload, read, or deletion. -pub async fn files_api_deprecated() -> (StatusCode, Json) { +/// Existing data remains available through the original read-only endpoints +/// while export tooling is in use. Upload and deletion requests do not reach +/// storage or repository layers. +pub async fn files_write_disabled() -> (StatusCode, Json) { ( StatusCode::GONE, Json(ErrorResponse::new( - "The Files API has been deprecated and is no longer available. Manage file content in your application and use stateless POST /v1/responses requests with store: false." + "The Files API is temporarily read-only while existing data remains available for export. This operation is no longer available. Only GET /v1/files, GET /v1/files/{file_id}, and GET /v1/files/{file_id}/content are supported." .to_string(), "gone".to_string(), )), @@ -302,7 +302,9 @@ pub async fn upload_file( ("purpose" = Option, Query, description = "Filter files by purpose") ), responses( - (status = 200, description = "List of files retrieved successfully", body = FileListResponse), + (status = 200, description = "List of files retrieved successfully", body = FileListResponse, + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse) ), @@ -415,7 +417,9 @@ pub async fn list_files( ("file_id" = String, Path, description = "The ID of the file to retrieve") ), responses( - (status = 200, description = "File information retrieved successfully", body = FileUploadResponse), + (status = 200, description = "File information retrieved successfully", body = FileUploadResponse, + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "File not found", body = ErrorResponse) @@ -583,7 +587,9 @@ pub async fn delete_file( ("file_id" = String, Path, description = "The ID of the file to retrieve content from") ), responses( - (status = 200, description = "File content retrieved successfully", content_type = "application/octet-stream"), + (status = 200, description = "File content retrieved successfully", content_type = "application/octet-stream", + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "File not found", body = ErrorResponse) From fd7a6c39a3fca3b65e808fc4956d1cb5466a7c4d Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:39:12 +0800 Subject: [PATCH 24/31] test(api): cover Stage I read-only views --- crates/api/src/openapi.rs | 19 +- crates/api/tests/e2e_all/api_keys.rs | 12 +- crates/api/tests/e2e_all/conversations.rs | 265 +++++++++++++++++++--- crates/api/tests/e2e_all/files.rs | 187 ++++++--------- crates/api/tests/e2e_all/vpc_login.rs | 6 +- 5 files changed, 329 insertions(+), 160 deletions(-) diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index f3bcfea6e..f2a700d35 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -7,7 +7,7 @@ use utoipa::{Modify, OpenApi}; #[openapi( info( title = "NEAR AI Cloud API", - description = "A comprehensive cloud API for AI model inference, conversation management, and organization administration.\n\n## Authentication\n\nThis API supports four authentication methods:\n\n1. **Access Token (JWT)**: Use `Authorization: Bearer ` with a short-lived JWT access token for most API endpoints. Obtain this by calling POST /users/me/access_tokens with a refresh token.\n2. **Refresh Token**: Use `Authorization: Bearer ` (prefix: `rt_`) only with POST /users/me/access_tokens to create new JWT access tokens. Obtained from OAuth login.\n3. **API Key (Programmatic Access)**: Use `Authorization: Bearer sk-` with an API key (prefix: `sk-`).\n4. **Reporting Token (Read-only Usage Reporting)**: Use `Authorization: Bearer rpt-` only with usage reporting endpoints.\n\nClick the **Authorize** button above to configure authentication.", + description = "A comprehensive cloud API for AI model inference, temporary confidential-data migration views, and organization administration.\n\n## Authentication\n\nThis API supports four authentication methods:\n\n1. **Access Token (JWT)**: Use `Authorization: Bearer ` with a short-lived JWT access token for most API endpoints. Obtain this by calling POST /users/me/access_tokens with a refresh token.\n2. **Refresh Token**: Use `Authorization: Bearer ` (prefix: `rt_`) only with POST /users/me/access_tokens to create new JWT access tokens. Obtained from OAuth login.\n3. **API Key (Programmatic Access)**: Use `Authorization: Bearer sk-` with an API key (prefix: `sk-`).\n4. **Reporting Token (Read-only Usage Reporting)**: Use `Authorization: Bearer rpt-` only with usage reporting endpoints.\n\nClick the **Authorize** button above to configure authentication.", version = "1.0.0", contact( name = "NEAR AI Team", @@ -25,7 +25,9 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Every successful Responses inference makes exactly one Chat Completions call. Only custom `function` tools are supported. They are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Server-executed tools (`web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`) and image-generation/editing models are rejected. The separate `POST /mcp` endpoint continues to expose its `web_search` tool independently of Responses; use `/v1/images/*` for image generation/editing. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Conversations, response history, and file input are rejected."), + (name = "Conversations", description = "Temporary authenticated, workspace-scoped read access for migration/export. Only `POST /v1/conversations/batch`, `GET /v1/conversations/{conversation_id}`, and `GET /v1/conversations/{conversation_id}/items` are available. Conversation creation and every mutation return `410 Gone`; this surface will be removed after data migration. Temporary-view responses use `Cache-Control: no-store`."), + (name = "Files", description = "Temporary authenticated, workspace-scoped read access for migration/export. Only `GET /v1/files`, `GET /v1/files/{file_id}`, and `GET /v1/files/{file_id}/content` are available. Upload and every mutation return `410 Gone`; this surface will be removed after data migration. Temporary-view responses use `Cache-Control: no-store`."), + (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Every successful Responses inference makes exactly one Chat Completions call. Only custom `function` tools are supported. They are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Server-executed tools (`web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`) and image-generation/editing models are rejected. The separate `POST /mcp` endpoint continues to expose its `web_search` tool independently of Responses; use `/v1/images/*` for image generation/editing. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Responses rejects conversation linkage, response history, and file input; the separate temporary Conversation and File read endpoints are not part of Responses inference."), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), @@ -56,6 +58,14 @@ use utoipa::{Modify, OpenApi}; // Model endpoints (public model catalog) crate::routes::models::list_models, crate::routes::models::get_model_by_name, + // Temporary read-only Conversation migration views + crate::routes::conversations::batch_get_conversations, + crate::routes::conversations::get_conversation, + crate::routes::conversations::list_conversation_items, + // Temporary read-only File migration views + crate::routes::files::list_files, + crate::routes::files::get_file, + crate::routes::files::get_file_content, // Response endpoints crate::routes::responses::create_response, // Organization endpoints @@ -226,6 +236,11 @@ use utoipa::{Modify, OpenApi}; AdminUserResponse, crate::routes::users::UpdateUserProfileRequest, crate::routes::users::UserStatusResponse, + // Temporary read-only Conversation migration-view models + BatchConversationsRequest, ConversationBatchResponse, ConversationObject, + ConversationItemList, + // Temporary read-only File migration-view models + FileUploadResponse, FileListResponse, // Response models crate::routes::responses::StatelessCreateResponseRequestSchema, ResponseObject, // Attestation models diff --git a/crates/api/tests/e2e_all/api_keys.rs b/crates/api/tests/e2e_all/api_keys.rs index 687e6b10c..fa9544f2b 100644 --- a/crates/api/tests/e2e_all/api_keys.rs +++ b/crates/api/tests/e2e_all/api_keys.rs @@ -390,7 +390,7 @@ async fn test_deleted_api_key_cannot_be_used() { let api_key = api_key_resp.key.clone().unwrap(); - // A valid key reaches the retired Files endpoint before deletion. + // A valid key reaches the temporary read-only Files view before deletion. let response = server .get("/v1/files?limit=1") .add_header("Authorization", format!("Bearer {api_key}")) @@ -398,8 +398,8 @@ async fn test_deleted_api_key_cannot_be_used() { assert_eq!( response.status_code(), - 410, - "Valid API key should reach the Files API retirement response before deletion" + 200, + "Valid API key should reach the temporary Files read view before deletion" ); // Delete the API key @@ -895,7 +895,7 @@ async fn test_api_key_authentication() { let (api_key, _) = create_org_and_api_key(&server).await; - // A valid API key reaches the retired Files endpoint. + // A valid API key reaches the temporary read-only Files view. let response = server .get("/v1/files?limit=1") .add_header("Authorization", format!("Bearer {api_key}")) @@ -903,8 +903,8 @@ async fn test_api_key_authentication() { assert_eq!( response.status_code(), - 410, - "Valid API key should reach the Files API retirement response" + 200, + "Valid API key should reach the temporary Files read view" ); // Test invalid API key diff --git a/crates/api/tests/e2e_all/conversations.rs b/crates/api/tests/e2e_all/conversations.rs index 1a9145d26..0df2b65fc 100644 --- a/crates/api/tests/e2e_all/conversations.rs +++ b/crates/api/tests/e2e_all/conversations.rs @@ -1,55 +1,254 @@ use crate::common::*; use axum::http::Method; +const UNKNOWN_CONVERSATION_ID: &str = "conv_00000000-0000-0000-0000-000000000000"; + +fn assert_no_store(response: &axum_test::TestResponse) { + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "temporary confidential-data migration views must not be cacheable" + ); +} + +fn assert_conversation_write_is_gone(response: axum_test::TestResponse) { + assert_eq!(response.status_code(), 410); + assert_no_store(&response); + + let error = response.json::(); + assert_eq!(error.error.r#type, "gone"); + assert_eq!( + error.error.code.as_deref(), + Some("conversation_write_disabled") + ); + assert!(error.error.message.contains("read-only")); +} + #[tokio::test] -async fn retired_conversation_routes_require_an_api_key_then_return_gone() { +async fn conversation_migration_views_require_an_api_key() { let server = setup_test_server().await; - let missing_auth = server.post("/v1/conversations").await; - assert_eq!(missing_auth.status_code(), 401); + assert_eq!( + server + .post("/v1/conversations/batch") + .json(&serde_json::json!({ "ids": [UNKNOWN_CONVERSATION_ID] })) + .await + .status_code(), + 401 + ); + assert_eq!( + server + .get(&format!("/v1/conversations/{UNKNOWN_CONVERSATION_ID}")) + .await + .status_code(), + 401 + ); + assert_eq!( + server + .get(&format!( + "/v1/conversations/{UNKNOWN_CONVERSATION_ID}/items" + )) + .await + .status_code(), + 401 + ); +} + +#[tokio::test] +async fn conversation_migration_views_reach_read_handlers_and_are_no_store() { + let server = setup_test_server().await; + let (api_key, _) = create_org_and_api_key(&server).await; - let invalid_auth = server - .post("/v1/conversations") - .add_header("Authorization", "Bearer sk-invalid") + let batch = server + .post("/v1/conversations/batch") + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ "ids": [UNKNOWN_CONVERSATION_ID] })) .await; - assert_eq!(invalid_auth.status_code(), 401); + assert_eq!(batch.status_code(), 200); + assert_no_store(&batch); + let batch = batch.json::(); + assert_eq!(batch.missing_ids, vec![UNKNOWN_CONVERSATION_ID.to_string()]); + + for path in [ + format!("/v1/conversations/{UNKNOWN_CONVERSATION_ID}"), + format!("/v1/conversations/{UNKNOWN_CONVERSATION_ID}/items"), + ] { + let response = server + .get(&path) + .add_header("Authorization", format!("Bearer {api_key}")) + .await; + assert_eq!( + response.status_code(), + 404, + "temporary view must reach its workspace-scoped read handler: {path}" + ); + assert_no_store(&response); + } +} +#[tokio::test] +async fn conversation_writes_and_unlisted_reads_remain_gone_after_authentication() { + let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; let routes = [ (Method::POST, "/v1/conversations"), - (Method::GET, "/v1/conversations/"), - (Method::POST, "/v1/conversations/batch"), - (Method::GET, "/v1/conversations/conv_example"), - (Method::POST, "/v1/conversations/conv_example"), - (Method::DELETE, "/v1/conversations/conv_example"), - (Method::POST, "/v1/conversations/conv_example/pin"), - (Method::DELETE, "/v1/conversations/conv_example/pin"), - (Method::POST, "/v1/conversations/conv_example/archive"), - (Method::DELETE, "/v1/conversations/conv_example/archive"), - (Method::POST, "/v1/conversations/conv_example/clone"), - (Method::GET, "/v1/conversations/conv_example/items"), - (Method::POST, "/v1/conversations/conv_example/items"), - (Method::PATCH, "/v1/conversations/conv_example/unknown"), + (Method::GET, "/v1/conversations"), + ( + Method::POST, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000", + ), + ( + Method::DELETE, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000", + ), + ( + Method::POST, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/pin", + ), + ( + Method::DELETE, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/pin", + ), + ( + Method::POST, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/archive", + ), + ( + Method::DELETE, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/archive", + ), + ( + Method::POST, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/clone", + ), + ( + Method::POST, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/items", + ), + ( + Method::PATCH, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/unknown", + ), ]; for (method, path) in routes { - let response = server - .method(method.clone(), path) - .add_header("Authorization", format!("Bearer {api_key}")) - .await; + assert_conversation_write_is_gone( + server + .method(method.clone(), path) + .add_header("Authorization", format!("Bearer {api_key}")) + .await, + ); + } +} + +#[tokio::test] +async fn openapi_advertises_only_temporary_read_only_migration_views() { + let server = setup_test_server().await; + let response = server.get("/api-docs/openapi.json").await; + assert_eq!(response.status_code(), 200); + + let openapi = response.json::(); + let paths = openapi["paths"] + .as_object() + .expect("OpenAPI paths must be an object"); + for (path, method) in [ + ("/v1/conversations/batch", "post"), + ("/v1/conversations/{conversation_id}", "get"), + ("/v1/conversations/{conversation_id}/items", "get"), + ("/v1/files", "get"), + ("/v1/files/{file_id}", "get"), + ("/v1/files/{file_id}/content", "get"), + ] { + let operation = paths.get(path).and_then(|path_item| path_item.get(method)); + assert!( + operation.is_some_and(serde_json::Value::is_object), + "OpenAPI must advertise temporary read-only migration view: {method} {path}" + ); assert_eq!( - response.status_code(), - 410, - "{method} {path} must return 410 Gone after authentication" + operation.unwrap()["security"], + serde_json::json!([{ "api_key": [] }]), + "{method} {path} must require an API key" + ); + assert!( + operation.unwrap()["responses"]["200"]["headers"]["Cache-Control"].is_object(), + "{method} {path} must document Cache-Control: no-store" ); + } - let error = response.json::(); - assert_eq!(error.error.r#type, "gone"); - assert_eq!( - error.error.code.as_deref(), - Some("conversation_api_retired") + for (path, method) in [ + ("/v1/conversations", "post"), + ("/v1/conversations/{conversation_id}", "post"), + ("/v1/conversations/{conversation_id}", "delete"), + ("/v1/conversations/{conversation_id}/pin", "post"), + ("/v1/conversations/{conversation_id}/archive", "post"), + ("/v1/conversations/{conversation_id}/clone", "post"), + ("/v1/conversations/{conversation_id}/items", "post"), + ("/v1/files", "post"), + ("/v1/files/{file_id}", "delete"), + ] { + assert!( + paths + .get(path) + .and_then(|path_item| path_item.get(method)) + .is_none(), + "OpenAPI must not advertise disabled mutation: {method} {path}" + ); + } + + let tags = openapi["tags"] + .as_array() + .expect("OpenAPI tags must be an array"); + for tag_name in ["Conversations", "Files"] { + let description = tags + .iter() + .find(|tag| tag["name"] == tag_name) + .and_then(|tag| tag["description"].as_str()) + .unwrap_or_else(|| panic!("missing {tag_name} tag description")); + assert!( + description.contains("Temporary authenticated, workspace-scoped read access"), + "{tag_name} must be documented as a temporary workspace-scoped read surface" + ); + assert!( + description.contains("410 Gone"), + "{tag_name} must document disabled mutations" + ); + assert!( + description.contains("Cache-Control: no-store"), + "{tag_name} must document the no-store response policy" + ); + } + + let schemas = openapi["components"]["schemas"] + .as_object() + .expect("OpenAPI schemas must be an object"); + for schema in [ + "ConversationObject", + "ConversationItemList", + "BatchConversationsRequest", + "ConversationBatchResponse", + "FileUploadResponse", + "FileListResponse", + ] { + assert!( + schemas.contains_key(schema), + "OpenAPI must expose temporary migration-view schema {schema}" + ); + } + for schema in [ + "CreateConversationRequest", + "UpdateConversationRequest", + "CreateConversationItemsRequest", + "ConversationDeleteResult", + "FileDeleteResponse", + "ExpiresAfter", + ] { + assert!( + !schemas.contains_key(schema), + "OpenAPI must not expose disabled mutation schema {schema}" ); - assert!(error.error.message.contains("POST /v1/responses")); } } diff --git a/crates/api/tests/e2e_all/files.rs b/crates/api/tests/e2e_all/files.rs index ef7f45b95..1dfda0291 100644 --- a/crates/api/tests/e2e_all/files.rs +++ b/crates/api/tests/e2e_all/files.rs @@ -1,143 +1,98 @@ use crate::common::*; +use axum::http::Method; + +const UNKNOWN_FILE_ID: &str = "file-00000000-0000-0000-0000-000000000000"; + +fn assert_no_store(response: &axum_test::TestResponse) { + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "temporary confidential-data migration views must not be cacheable" + ); +} -const FILES_API_GONE_MESSAGE: &str = - "The Files API has been deprecated and is no longer available. Manage file content in your application and use stateless POST /v1/responses requests with store: false."; - -fn assert_files_api_is_gone(response: axum_test::TestResponse) { +fn assert_file_write_is_gone(response: axum_test::TestResponse) { assert_eq!(response.status_code(), 410); + assert_no_store(&response); let error = response.json::(); assert_eq!(error.error.r#type, "gone"); - assert_eq!(error.error.message, FILES_API_GONE_MESSAGE); + assert!(error.error.message.contains("read-only")); } #[tokio::test] -async fn test_files_api_returns_gone_for_all_legacy_routes() { +async fn file_migration_views_require_an_api_key() { let server = setup_test_server().await; - let (api_key, _) = create_org_and_api_key(&server).await; - - assert_files_api_is_gone( - server - .post("/v1/files") - .add_header("Authorization", format!("Bearer {api_key}")) - .json(&serde_json::json!({"purpose": "user_data"})) - .await, - ); - - assert_files_api_is_gone( - server - .get("/v1/files?limit=1") - .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); - - assert_files_api_is_gone( - server - .get("/v1/files/") - .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); - - assert_files_api_is_gone( - server - .get("/v1/files/file-00000000-0000-0000-0000-000000000000") - .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); - - assert_files_api_is_gone( - server - .delete("/v1/files/file-00000000-0000-0000-0000-000000000000") - .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); - assert_files_api_is_gone( - server - .get("/v1/files/file-00000000-0000-0000-0000-000000000000/content") - .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); + for path in [ + "/v1/files".to_string(), + format!("/v1/files/{UNKNOWN_FILE_ID}"), + format!("/v1/files/{UNKNOWN_FILE_ID}/content"), + ] { + assert_eq!( + server.get(&path).await.status_code(), + 401, + "temporary view must require an API key: {path}" + ); + } } #[tokio::test] -async fn test_files_api_returns_gone_for_other_methods_and_subpaths() { +async fn file_migration_views_reach_read_handlers_and_are_no_store() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; - assert_files_api_is_gone( - server - .patch("/v1/files/file-00000000-0000-0000-0000-000000000000") - .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); + let list = server + .get("/v1/files?limit=1") + .add_header("Authorization", format!("Bearer {api_key}")) + .await; + assert_eq!(list.status_code(), 200); + assert_no_store(&list); - assert_files_api_is_gone( - server - .put("/v1/files/legacy/nested/path") + for path in [ + format!("/v1/files/{UNKNOWN_FILE_ID}"), + format!("/v1/files/{UNKNOWN_FILE_ID}/content"), + ] { + let response = server + .get(&path) .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); + .await; + assert_eq!( + response.status_code(), + 404, + "temporary view must reach its workspace-scoped read handler: {path}" + ); + assert_no_store(&response); + } } #[tokio::test] -async fn test_files_api_requires_authentication_before_returning_gone() { +async fn file_writes_and_unlisted_subpaths_remain_gone_after_authentication() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; - - assert_files_api_is_gone( - server - .get("/v1/files") - .add_header("Authorization", format!("Bearer {api_key}")) - .await, - ); - - let invalid_key_response = server - .get("/v1/files") - .add_header("Authorization", "Bearer invalid_key_12345") - .await; - assert_eq!(invalid_key_response.status_code(), 401); - - let missing_key_response = server.get("/v1/files").await; - assert_eq!(missing_key_response.status_code(), 401); -} - -#[tokio::test] -async fn test_openapi_does_not_advertise_files_api() { - let server = setup_test_server().await; - - let response = server.get("/api-docs/openapi.json").await; - assert_eq!(response.status_code(), 200); - - let openapi = response.json::(); - let paths = openapi["paths"] - .as_object() - .expect("OpenAPI paths must be an object"); - assert!( - paths.keys().all(|path| !path.starts_with("/v1/files")), - "OpenAPI must not expose retired Files API paths" - ); - - let tags = openapi["tags"] - .as_array() - .expect("OpenAPI tags must be an array"); - assert!( - tags.iter().all(|tag| tag["name"] != "Files"), - "OpenAPI must not expose the Files tag" - ); - - let schemas = openapi["components"]["schemas"] - .as_object() - .expect("OpenAPI schemas must be an object"); - for schema in [ - "FileUploadResponse", - "FileListResponse", - "FileDeleteResponse", - "ExpiresAfter", - ] { - assert!( - !schemas.contains_key(schema), - "OpenAPI must not expose the retired {schema} schema" + let routes = [ + (Method::POST, "/v1/files"), + ( + Method::DELETE, + "/v1/files/file-00000000-0000-0000-0000-000000000000", + ), + ( + Method::PATCH, + "/v1/files/file-00000000-0000-0000-0000-000000000000", + ), + (Method::PUT, "/v1/files/legacy/nested/path"), + (Method::GET, "/v1/files/"), + ]; + + for (method, path) in routes { + assert_file_write_is_gone( + server + .method(method.clone(), path) + .add_header("Authorization", format!("Bearer {api_key}")) + .await, ); } } diff --git a/crates/api/tests/e2e_all/vpc_login.rs b/crates/api/tests/e2e_all/vpc_login.rs index 0612cc514..ca1578f28 100644 --- a/crates/api/tests/e2e_all/vpc_login.rs +++ b/crates/api/tests/e2e_all/vpc_login.rs @@ -284,7 +284,7 @@ async fn test_vpc_login_api_key_works() { let body = response.json::(); - // A valid API key reaches the retired Files endpoint. + // A valid API key reaches the temporary read-only Files view. let auth_response = server .get("/v1/files?limit=1") .add_header("Authorization", format!("Bearer {}", body.api_key)) @@ -293,8 +293,8 @@ async fn test_vpc_login_api_key_works() { assert_eq!( auth_response.status_code(), - 410, - "API key from VPC login should reach the Files API retirement response" + 200, + "API key from VPC login should reach the temporary Files read view" ); println!("✅ API key from VPC login works correctly"); From 375f5d76e2ca1abf3d2cb3af8f6ccc5f88178030 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:39:15 +0800 Subject: [PATCH 25/31] docs(api): describe temporary read-only migration views --- README.md | 26 +++++++++++++++++++++----- docs/local-development.md | 16 ++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c10ae2c6f..d5037e1b9 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,27 @@ Once all checks pass, you're ready to commit! ## API Documentation -### Retired Conversations API - -The `/v1/conversations` API is retired and every request to that path returns -`410 Gone`. Use stateless `POST /v1/responses` requests with `store: false` and -send any conversation history needed for inference in each request. +### Temporary migration read APIs + +During Stage I of the confidential-data migration, cloud-api keeps a small, +authenticated, workspace-scoped read surface so existing migration/export +tooling can retrieve data it already identifies. This is not a new export API +and does not add a Conversation-list endpoint. + +- Conversations: `POST /v1/conversations/batch`, + `GET /v1/conversations/{conversation_id}`, and + `GET /v1/conversations/{conversation_id}/items`. +- Files: `GET /v1/files`, `GET /v1/files/{file_id}`, and + `GET /v1/files/{file_id}/content`. + +Each temporary view requires an API key, is scoped to that key's workspace, and +returns `Cache-Control: no-store`. Creation, upload, deletion, and every other +Conversation or File mutation return `410 Gone`. The temporary views will be +removed after data migration. + +`POST /v1/responses` remains stateless (`store: false`) and does not accept a +Conversation reference, response history, or File input. Clients must send any +inference history needed for a request themselves. Interactive API documentation is available when running the server: diff --git a/docs/local-development.md b/docs/local-development.md index 6e62809ee..8972a1290 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -273,6 +273,8 @@ Provider refresh runs every 300s by default | `GET /v1/models` | public | OpenAI-compatible model catalog with pricing metadata | | `POST /v1/chat/completions` | API key | OpenAI-compatible. Add `"stream": true` for SSE | | `POST /v1/responses` | API key | Stateless `store: false` inference; client-managed function tools only | +| `POST /v1/conversations/batch`, `GET /v1/conversations/{id}`, `GET /v1/conversations/{id}/items` | API key | Temporary workspace-scoped migration/export reads; all Conversation writes return `410` | +| `GET /v1/files`, `GET /v1/files/{id}`, `GET /v1/files/{id}/content` | API key | Temporary workspace-scoped migration/export reads; upload and all File writes return `410` | | `POST /mcp` | API key | Independent MCP server exposing the `web_search` tool | | `GET /v1/attestation/report` | API key | TEE attestation (503 outside a CVM unless `DEV=true` in debug builds) | | `GET /v1/attestation/ita-token` | public | Intel Trust Authority JWT wrapper (requires ITA env vars) | @@ -318,8 +320,18 @@ Completions call. This does not affect the separate `POST /mcp` MCP server, which continues to expose its independent `web_search` tool, or the `/v1/images/*` endpoints. -Stateful operations are also rejected: Conversations, response history, and -file input. +Responses itself rejects conversation linkage, response history, and file +input. During the temporary migration/export window, the authenticated, +workspace-scoped read views remain available: + +- `POST /v1/conversations/batch`, `GET /v1/conversations/{id}`, and + `GET /v1/conversations/{id}/items`; +- `GET /v1/files`, `GET /v1/files/{id}`, and `GET /v1/files/{id}/content`. + +These views preserve existing retrieval behavior only; they are not a new +export API or a new Conversation-list endpoint. They send `Cache-Control: +no-store`. Creation, upload, deletion, pinning, archiving, cloning, item +creation, and every other Conversation or File mutation return `410 Gone`. ## 7. Troubleshooting From 4e592981b608432979179331906bd7767b3623ee Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:47:53 +0800 Subject: [PATCH 26/31] docs(api): align CLAUDE with Stage I read views --- CLAUDE.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f454a5a4c..e3cd74732 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,8 +200,9 @@ POST /v1/responses - Raw request/response content, response items, and conversation history are not persisted. - **Existing completed-response attestation is preserved best-effort**: when its signature write succeeds, `GET /v1/signature/resp_*` can retrieve the response ID and signatures over SHA-256 request/response digests. The signature material contains no raw request or response content. A disconnected stream has no completed `resp_*` attestation record or legacy disconnect fallback. - Event types: `response.created`, `response.output_text.delta`, `response.completed`, `response.failed` -- `/v1/conversations/*` and `/v1/files/*` return authenticated `410 Gone` responses. -- The following stateful operations are rejected: file input/file search, function tools and function-call continuation, code interpreter, computer, and every MCP approval mode other than `require_approval: "never"`. Only request-scoped MCP calls with that exact mode are supported. +- Only custom `type: "function"` tools are supported. They are client-managed: Cloud returns a `function_call` but never executes it; the client sends the original call and its matching `function_call_output` in a later fresh `store: false` request with its own history. +- Server-executed Responses tools—including `web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`—plus file input and image-generation/editing output models are rejected. The independent root `POST /mcp` endpoint continues to expose web search and is not part of Responses execution. +- During Stage I, existing Conversation/File data remains available only through authenticated, workspace-scoped migration views: `POST /v1/conversations/batch`, `GET /v1/conversations/{conversation_id}`, `GET /v1/conversations/{conversation_id}/items`, `GET /v1/files`, `GET /v1/files/{file_id}`, and `GET /v1/files/{file_id}/content`. They return `Cache-Control: no-store`; every Conversation/File mutation and unsupported legacy path returns `410 Gone`. **Streaming Flow**: ``` @@ -258,14 +259,14 @@ Located in `crates/services/src/`: - `workspace` - Workspace CRUD, settings - `user` - User profiles, session management - `completions` - AI completion orchestration -- `conversations` - Legacy module; its public API surface is retired -- `responses` - Request-scoped, stateless response orchestration and supported one-turn tools +- `conversations` - Legacy state module with temporary Stage I migration read views; public mutations return `410 Gone` +- `responses` - Request-scoped, stateless response orchestration with client-managed custom function calls - `attestation` - TEE attestation reports, chat signatures - `models` - Model catalog and pricing - `usage` - Token tracking, limit enforcement, billing - `inference_provider_pool` - Model discovery, load balancing - `mcp` - Model Context Protocol client management -- `files` - Legacy module; its public API surface is retired +- `files` - Legacy state module with temporary Stage I migration read views; public mutations return `410 Gone` - `metrics` - OpenTelemetry metrics - `admin` - Admin operations, analytics - `common` - Shared utilities @@ -278,13 +279,13 @@ Located in `crates/api/src/routes/`: - `workspaces.rs` - Workspace & API key management - `users.rs` - User profile, invitations, sessions - `completions.rs` - Chat & text completions -- `conversations.rs` - Retired API surface (authenticated `410 Gone`) -- `responses.rs` - Stateless AI response streaming +- `conversations.rs` - Temporary authenticated, no-store Stage I migration read views; mutations return `410 Gone` +- `responses.rs` - Stateless AI response streaming with client-managed custom function calls - `models.rs` - Model catalog - `usage.rs` - Usage tracking, billing - `attestation.rs` - TEE verification, signatures - `admin.rs` - Admin endpoints -- `files.rs` - Retired API surface (authenticated `410 Gone`) +- `files.rs` - Temporary authenticated, no-store Stage I migration read views; mutations return `410 Gone` - `health.rs` - Health checks - `api.rs` - API versioning From c907eb599534e5c9adc6f009faeb202317e07dad Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:10:48 +0800 Subject: [PATCH 27/31] fix(api): retain 410 for nested retired routes --- crates/api/src/lib.rs | 56 ++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index a5e6380f2..912b0a353 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1776,8 +1776,8 @@ pub fn build_conversation_routes( /// /// Kept separate from service wiring so route precedence can be tested without /// a database; production uses this helper directly. The per-route fallbacks -/// make unsupported methods 410, while the nested fallback handles unknown -/// legacy descendants without conflicting with parameter routes. +/// make unsupported methods 410, while ordinary descendant routes handle +/// unknown legacy paths through the outer `/v1` nest. fn build_read_only_conversation_route_layout( batch: MethodRouter, conversation: MethodRouter, @@ -1790,10 +1790,14 @@ where T: 'static, { let descendants = Router::new() + .route("/", any(write_disabled.clone())) .route("/batch", batch) .route("/{conversation_id}", conversation) .route("/{conversation_id}/items", items) - .fallback(write_disabled.clone()); + .route( + "/{conversation_id}/{*legacy_path}", + any(write_disabled.clone()), + ); Router::new() .route("/conversations", any(write_disabled)) @@ -1897,8 +1901,8 @@ pub fn build_files_routes(app_state: AppState, auth_state_middleware: &AuthState /// /// Like Conversations, this is shared by production and route-precedence /// tests. Only the original GET views receive service handlers; other methods -/// and descendants land on the authenticated 410 router through a scoped -/// nested fallback. +/// and descendants land on the authenticated 410 router through ordinary +/// routes that survive the outer `/v1` nest. fn build_read_only_file_route_layout( list: MethodRouter, file: MethodRouter, @@ -1911,9 +1915,10 @@ where T: 'static, { let descendants = Router::new() + .route("/", any(write_disabled.clone())) .route("/{file_id}/content", content) .route("/{file_id}", file) - .fallback(write_disabled); + .route("/{file_id}/{*legacy_path}", any(write_disabled)); Router::new() .route("/files", list) @@ -2858,17 +2863,19 @@ mod tests { ) .layer(map_response(no_store_response)); let app = Router::new() - .merge(conversation_routes) - .merge(file_routes) + .nest( + "/v1", + Router::new().merge(conversation_routes).merge(file_routes), + ) .fallback(unrelated_route); for (method, path) in [ - ("POST", "/conversations/batch"), - ("GET", "/conversations/conv_example"), - ("GET", "/conversations/conv_example/items"), - ("GET", "/files"), - ("GET", "/files/file_example"), - ("GET", "/files/file_example/content"), + ("POST", "/v1/conversations/batch"), + ("GET", "/v1/conversations/conv_example"), + ("GET", "/v1/conversations/conv_example/items"), + ("GET", "/v1/files"), + ("GET", "/v1/files/file_example"), + ("GET", "/v1/files/file_example/content"), ] { let response = app .clone() @@ -2893,16 +2900,17 @@ mod tests { } for (method, path) in [ - ("POST", "/conversations"), - ("GET", "/conversations/"), - ("GET", "/conversations/batch"), - ("POST", "/conversations/conv_example"), - ("POST", "/conversations/conv_example/items"), - ("PATCH", "/conversations/conv_example/unknown"), - ("POST", "/files"), - ("GET", "/files/"), - ("DELETE", "/files/file_example"), - ("PUT", "/files/legacy/nested/path"), + ("POST", "/v1/conversations"), + ("GET", "/v1/conversations/"), + ("GET", "/v1/conversations/batch"), + ("POST", "/v1/conversations/conv_example"), + ("POST", "/v1/conversations/conv_example/items"), + ("POST", "/v1/conversations/conv_example/pin"), + ("PATCH", "/v1/conversations/conv_example/unknown"), + ("POST", "/v1/files"), + ("GET", "/v1/files/"), + ("DELETE", "/v1/files/file_example"), + ("PUT", "/v1/files/legacy/nested/path"), ] { let response = app .clone() From cfef47d15c5c0b89bbb361fda919bea110824881 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:18:57 +0800 Subject: [PATCH 28/31] test(responses): allow parse-time tool rejection --- crates/api/tests/e2e_all/function_tools.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/api/tests/e2e_all/function_tools.rs b/crates/api/tests/e2e_all/function_tools.rs index a45596856..d7e92c93c 100644 --- a/crates/api/tests/e2e_all/function_tools.rs +++ b/crates/api/tests/e2e_all/function_tools.rs @@ -308,7 +308,6 @@ async fn stateless_mcp_tools_are_rejected_before_provider_work() { assert_eq!(response.status_code(), 400, "response: {}", response.text()); let error = response.json::(); assert_eq!(error.error.r#type, "invalid_request_error"); - assert!(error.error.message.contains("mcp is not supported")); assert!( mock.last_chat_params().await.is_none(), "MCP should be rejected before provider work" From 49ee41c3876ab4954f09aa8af2550352fbddf926 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:37:13 +0800 Subject: [PATCH 29/31] fix(api): retain resource deletion during Stage I --- CLAUDE.md | 10 +- README.md | 22 +++-- crates/api/src/lib.rs | 107 ++++++++++++++++------ crates/api/src/openapi.rs | 20 ++-- crates/api/src/routes/conversations.rs | 12 ++- crates/api/src/routes/files.rs | 12 ++- crates/api/tests/e2e_all/conversations.rs | 53 +++++++---- crates/api/tests/e2e_all/files.rs | 33 +++++-- docs/local-development.md | 20 ++-- 9 files changed, 196 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e3cd74732..ffb9ad1bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,7 +202,7 @@ POST /v1/responses - Event types: `response.created`, `response.output_text.delta`, `response.completed`, `response.failed` - Only custom `type: "function"` tools are supported. They are client-managed: Cloud returns a `function_call` but never executes it; the client sends the original call and its matching `function_call_output` in a later fresh `store: false` request with its own history. - Server-executed Responses tools—including `web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`—plus file input and image-generation/editing output models are rejected. The independent root `POST /mcp` endpoint continues to expose web search and is not part of Responses execution. -- During Stage I, existing Conversation/File data remains available only through authenticated, workspace-scoped migration views: `POST /v1/conversations/batch`, `GET /v1/conversations/{conversation_id}`, `GET /v1/conversations/{conversation_id}/items`, `GET /v1/files`, `GET /v1/files/{file_id}`, and `GET /v1/files/{file_id}/content`. They return `Cache-Control: no-store`; every Conversation/File mutation and unsupported legacy path returns `410 Gone`. +- During Stage I, existing Conversation/File data remains available through authenticated, workspace-scoped migration routes: `POST /v1/conversations/batch`, `GET /v1/conversations/{conversation_id}`, `GET /v1/conversations/{conversation_id}/items`, `DELETE /v1/conversations/{conversation_id}`, `GET /v1/files`, `GET /v1/files/{file_id}`, `GET /v1/files/{file_id}/content`, and `DELETE /v1/files/{file_id}`. They return `Cache-Control: no-store`; creation, upload, all other mutations, and unsupported legacy paths return `410 Gone`. **Streaming Flow**: ``` @@ -259,14 +259,14 @@ Located in `crates/services/src/`: - `workspace` - Workspace CRUD, settings - `user` - User profiles, session management - `completions` - AI completion orchestration -- `conversations` - Legacy state module with temporary Stage I migration read views; public mutations return `410 Gone` +- `conversations` - Legacy state module with temporary Stage I migration routes; only per-conversation deletion remains enabled - `responses` - Request-scoped, stateless response orchestration with client-managed custom function calls - `attestation` - TEE attestation reports, chat signatures - `models` - Model catalog and pricing - `usage` - Token tracking, limit enforcement, billing - `inference_provider_pool` - Model discovery, load balancing - `mcp` - Model Context Protocol client management -- `files` - Legacy state module with temporary Stage I migration read views; public mutations return `410 Gone` +- `files` - Legacy state module with temporary Stage I migration routes; only per-file deletion remains enabled - `metrics` - OpenTelemetry metrics - `admin` - Admin operations, analytics - `common` - Shared utilities @@ -279,13 +279,13 @@ Located in `crates/api/src/routes/`: - `workspaces.rs` - Workspace & API key management - `users.rs` - User profile, invitations, sessions - `completions.rs` - Chat & text completions -- `conversations.rs` - Temporary authenticated, no-store Stage I migration read views; mutations return `410 Gone` +- `conversations.rs` - Temporary authenticated, no-store Stage I migration routes; only per-conversation deletion remains enabled - `responses.rs` - Stateless AI response streaming with client-managed custom function calls - `models.rs` - Model catalog - `usage.rs` - Usage tracking, billing - `attestation.rs` - TEE verification, signatures - `admin.rs` - Admin endpoints -- `files.rs` - Temporary authenticated, no-store Stage I migration read views; mutations return `410 Gone` +- `files.rs` - Temporary authenticated, no-store Stage I migration routes; only per-file deletion remains enabled - `health.rs` - Health checks - `api.rs` - API versioning diff --git a/README.md b/README.md index d5037e1b9..62954e17f 100644 --- a/README.md +++ b/README.md @@ -171,23 +171,25 @@ Once all checks pass, you're ready to commit! ## API Documentation -### Temporary migration read APIs +### Temporary migration APIs During Stage I of the confidential-data migration, cloud-api keeps a small, -authenticated, workspace-scoped read surface so existing migration/export -tooling can retrieve data it already identifies. This is not a new export API -and does not add a Conversation-list endpoint. +authenticated, workspace-scoped surface so existing migration/export tooling +can retrieve data it already identifies and existing account deletion can +complete. This is not a new export API and does not add a Conversation-list +endpoint. - Conversations: `POST /v1/conversations/batch`, `GET /v1/conversations/{conversation_id}`, and - `GET /v1/conversations/{conversation_id}/items`. + `GET /v1/conversations/{conversation_id}/items`, plus + `DELETE /v1/conversations/{conversation_id}`. - Files: `GET /v1/files`, `GET /v1/files/{file_id}`, and - `GET /v1/files/{file_id}/content`. + `GET /v1/files/{file_id}/content`, plus `DELETE /v1/files/{file_id}`. -Each temporary view requires an API key, is scoped to that key's workspace, and -returns `Cache-Control: no-store`. Creation, upload, deletion, and every other -Conversation or File mutation return `410 Gone`. The temporary views will be -removed after data migration. +Each temporary route requires an API key, is scoped to that key's workspace, +and returns `Cache-Control: no-store`. Creation, upload, and every Conversation +or File mutation other than the existing per-resource `DELETE` routes return +`410 Gone`. The temporary routes will be removed after data migration. `POST /v1/responses` remains stateless (`store: false`) and does not accept a Conversation reference, response history, or File input. Clients must send any diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 912b0a353..01e22f699 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1742,20 +1742,23 @@ pub fn build_mcp_routes( )) } -/// Build the temporary read-only Conversation API surface. +/// Build the temporary Conversation API migration surface. /// /// Existing conversation data remains available through its original view -/// routes so chat-api can export it. All mutations and unsupported legacy -/// paths stay authenticated and return 410 until the data-retention migration -/// is complete. +/// routes so chat-api can export it. The existing per-conversation DELETE +/// route remains available for account deletion; all other mutations and +/// unsupported legacy paths stay authenticated and return 410 until the +/// data-retention migration is complete. pub fn build_conversation_routes( conversation_service: Arc, auth_state_middleware: &AuthState, ) -> Router { - build_read_only_conversation_route_layout( + build_stage_one_conversation_route_layout( post(conversations::batch_get_conversations) .fallback(conversations::conversation_write_disabled), - get(conversations::get_conversation).fallback(conversations::conversation_write_disabled), + get(conversations::get_conversation) + .delete(conversations::delete_conversation) + .fallback(conversations::conversation_write_disabled), get(conversations::list_conversation_items) .fallback(conversations::conversation_write_disabled), conversations::conversation_write_disabled, @@ -1772,13 +1775,13 @@ pub fn build_conversation_routes( .layer(map_response(no_store_response)) } -/// Install the exact temporary Conversation API route layout. +/// Install the exact Stage I Conversation API route layout. /// /// Kept separate from service wiring so route precedence can be tested without -/// a database; production uses this helper directly. The per-route fallbacks -/// make unsupported methods 410, while ordinary descendant routes handle -/// unknown legacy paths through the outer `/v1` nest. -fn build_read_only_conversation_route_layout( +/// a database; production uses this helper directly. It preserves the +/// existing workspace-scoped DELETE route needed by account deletion, while +/// per-route fallbacks make every other unsupported method 410. +fn build_stage_one_conversation_route_layout( batch: MethodRouter, conversation: MethodRouter, items: MethodRouter, @@ -1873,17 +1876,22 @@ pub fn build_workspace_routes(app_state: AppState, auth_state_middleware: &AuthS )) } -/// Build the temporary read-only Files API surface. +/// Build the temporary Files API migration surface. /// /// Existing metadata and content stay available through the original GET -/// routes so chat-api can export data. Uploads, deletion, and unsupported +/// routes so chat-api can export data. The existing per-file DELETE route +/// remains available for account deletion; uploads and all other unsupported /// legacy paths remain authenticated 410 responses and never reach storage. pub fn build_files_routes(app_state: AppState, auth_state_middleware: &AuthState) -> Router { - use crate::routes::files::{files_write_disabled, get_file, get_file_content, list_files}; + use crate::routes::files::{ + delete_file, files_write_disabled, get_file, get_file_content, list_files, + }; - build_read_only_file_route_layout( + build_stage_one_file_route_layout( get(list_files).fallback(files_write_disabled), - get(get_file).fallback(files_write_disabled), + get(get_file) + .delete(delete_file) + .fallback(files_write_disabled), get(get_file_content).fallback(files_write_disabled), files_write_disabled, ) @@ -1897,13 +1905,13 @@ pub fn build_files_routes(app_state: AppState, auth_state_middleware: &AuthState .layer(map_response(no_store_response)) } -/// Install the exact temporary Files API route layout. +/// Install the exact Stage I Files API route layout. /// /// Like Conversations, this is shared by production and route-precedence -/// tests. Only the original GET views receive service handlers; other methods -/// and descendants land on the authenticated 410 router through ordinary -/// routes that survive the outer `/v1` nest. -fn build_read_only_file_route_layout( +/// tests. The original GET views and per-file DELETE receive service handlers; +/// other methods and descendants land on the authenticated 410 router through +/// ordinary routes that survive the outer `/v1` nest. +fn build_stage_one_file_route_layout( list: MethodRouter, file: MethodRouter, content: MethodRouter, @@ -2831,8 +2839,43 @@ mod tests { assert!(!properties.contains_key("resultJson")); } + #[test] + fn stage_one_openapi_exposes_only_allowed_resource_deletes() { + let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let paths = spec["paths"] + .as_object() + .expect("OpenAPI paths must be an object"); + + for path in ["/v1/conversations/{conversation_id}", "/v1/files/{file_id}"] { + let delete = paths + .get(path) + .and_then(|path_item| path_item.get("delete")); + assert!( + delete.is_some_and(serde_json::Value::is_object), + "OpenAPI must expose the existing DELETE route: {path}" + ); + assert_eq!( + delete.unwrap()["security"], + serde_json::json!([{ "api_key": [] }]), + "{path} DELETE must require an API key" + ); + assert!( + delete.unwrap()["responses"]["200"]["headers"]["Cache-Control"].is_object(), + "{path} DELETE must document Cache-Control: no-store" + ); + } + + let conversation = paths + .get("/v1/conversations/{conversation_id}") + .expect("Conversation path must be present"); + assert!(conversation.get("post").is_none()); + + let files = paths.get("/v1/files").expect("Files path must be present"); + assert!(files.get("post").is_none()); + } + #[tokio::test] - async fn read_only_data_route_layout_preserves_views_and_rejects_mutations() { + async fn stage_one_data_route_layout_preserves_views_and_only_allowed_deletions() { async fn view() -> StatusCode { StatusCode::OK } @@ -2848,16 +2891,20 @@ mod tests { // These are the production route-layout helpers. Using lightweight // handlers here isolates Axum's static/parameter/catch-all matching // from database state while proving the exact public layout builds. - let conversation_routes = build_read_only_conversation_route_layout( + let conversation_routes = build_stage_one_conversation_route_layout( axum::routing::post(view).fallback(write_disabled), - axum::routing::get(view).fallback(write_disabled), + axum::routing::get(view) + .delete(view) + .fallback(write_disabled), axum::routing::get(view).fallback(write_disabled), write_disabled, ) .layer(map_response(no_store_response)); - let file_routes = build_read_only_file_route_layout( - axum::routing::get(view).fallback(write_disabled), + let file_routes = build_stage_one_file_route_layout( axum::routing::get(view).fallback(write_disabled), + axum::routing::get(view) + .delete(view) + .fallback(write_disabled), axum::routing::get(view).fallback(write_disabled), write_disabled, ) @@ -2872,9 +2919,11 @@ mod tests { for (method, path) in [ ("POST", "/v1/conversations/batch"), ("GET", "/v1/conversations/conv_example"), + ("DELETE", "/v1/conversations/conv_example"), ("GET", "/v1/conversations/conv_example/items"), ("GET", "/v1/files"), ("GET", "/v1/files/file_example"), + ("DELETE", "/v1/files/file_example"), ("GET", "/v1/files/file_example/content"), ] { let response = app @@ -2903,13 +2952,17 @@ mod tests { ("POST", "/v1/conversations"), ("GET", "/v1/conversations/"), ("GET", "/v1/conversations/batch"), + ("DELETE", "/v1/conversations/batch"), ("POST", "/v1/conversations/conv_example"), ("POST", "/v1/conversations/conv_example/items"), ("POST", "/v1/conversations/conv_example/pin"), + ("DELETE", "/v1/conversations/conv_example/pin"), + ("DELETE", "/v1/conversations/conv_example/unknown"), ("PATCH", "/v1/conversations/conv_example/unknown"), ("POST", "/v1/files"), + ("DELETE", "/v1/files"), ("GET", "/v1/files/"), - ("DELETE", "/v1/files/file_example"), + ("DELETE", "/v1/files/file_example/content"), ("PUT", "/v1/files/legacy/nested/path"), ] { let response = app diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index f2a700d35..804842108 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -25,9 +25,9 @@ use utoipa::{Modify, OpenApi}; (name = "Score", description = "Text similarity scoring endpoints"), (name = "Privacy", description = "Privacy classification (PII span detection) endpoints"), (name = "Models", description = "Public model catalog and information"), - (name = "Conversations", description = "Temporary authenticated, workspace-scoped read access for migration/export. Only `POST /v1/conversations/batch`, `GET /v1/conversations/{conversation_id}`, and `GET /v1/conversations/{conversation_id}/items` are available. Conversation creation and every mutation return `410 Gone`; this surface will be removed after data migration. Temporary-view responses use `Cache-Control: no-store`."), - (name = "Files", description = "Temporary authenticated, workspace-scoped read access for migration/export. Only `GET /v1/files`, `GET /v1/files/{file_id}`, and `GET /v1/files/{file_id}/content` are available. Upload and every mutation return `410 Gone`; this surface will be removed after data migration. Temporary-view responses use `Cache-Control: no-store`."), - (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Every successful Responses inference makes exactly one Chat Completions call. Only custom `function` tools are supported. They are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Server-executed tools (`web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`) and image-generation/editing models are rejected. The separate `POST /mcp` endpoint continues to expose its `web_search` tool independently of Responses; use `/v1/images/*` for image generation/editing. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Responses rejects conversation linkage, response history, and file input; the separate temporary Conversation and File read endpoints are not part of Responses inference."), + (name = "Conversations", description = "Temporary authenticated, workspace-scoped migration access for export and account deletion. Only `POST /v1/conversations/batch`, `GET /v1/conversations/{conversation_id}`, `GET /v1/conversations/{conversation_id}/items`, and `DELETE /v1/conversations/{conversation_id}` are available. Conversation creation and every other mutation return `410 Gone`; this surface will be removed after data migration. Migration responses use `Cache-Control: no-store`."), + (name = "Files", description = "Temporary authenticated, workspace-scoped migration access for export and account deletion. Only `GET /v1/files`, `GET /v1/files/{file_id}`, `GET /v1/files/{file_id}/content`, and `DELETE /v1/files/{file_id}` are available. Upload and every other mutation return `410 Gone`; this surface will be removed after data migration. Migration responses use `Cache-Control: no-store`."), + (name = "Responses", description = "Stateless response inference (`store: false` only). Raw request/response content, response items, and history are not persisted. Clients must include any prior context in each request. Every successful Responses inference makes exactly one Chat Completions call. Only custom `function` tools are supported. They are client-managed: Cloud returns `function_call` items but never executes them; a later `store: false` request replays the individual call (the raw item from output is accepted) with its matching `function_call_output`, alongside caller-managed message history and the same function tool definitions. The minimal replay path also accepts assistant `message` text parts of type `output_text`, but not reasoning or arbitrary full `response.output` items. Server-executed tools (`web_search`, `web_context_search`, `file_search`, `code_interpreter`, `computer`, and remote `mcp`) and image-generation/editing models are rejected. The separate `POST /mcp` endpoint continues to expose its `web_search` tool independently of Responses; use `/v1/images/*` for image generation/editing. Existing completed-response gateway attestation is preserved best-effort: when the signature write succeeds, `GET /v1/signature/resp_*` retrieves signatures over SHA-256 request/response digests, never raw content. Interrupted streams create no `resp_*` attestation record or legacy disconnect fallback. Responses rejects conversation linkage, response history, and file input; the separate temporary Conversation and File migration endpoints are not part of Responses inference."), (name = "Organizations", description = "Organization management"), (name = "Organization Members", description = "Organization member and invitation management"), (name = "Workspaces", description = "Workspace and API key management"), @@ -58,14 +58,16 @@ use utoipa::{Modify, OpenApi}; // Model endpoints (public model catalog) crate::routes::models::list_models, crate::routes::models::get_model_by_name, - // Temporary read-only Conversation migration views + // Temporary Conversation migration routes crate::routes::conversations::batch_get_conversations, crate::routes::conversations::get_conversation, crate::routes::conversations::list_conversation_items, - // Temporary read-only File migration views + crate::routes::conversations::delete_conversation, + // Temporary File migration routes crate::routes::files::list_files, crate::routes::files::get_file, crate::routes::files::get_file_content, + crate::routes::files::delete_file, // Response endpoints crate::routes::responses::create_response, // Organization endpoints @@ -236,11 +238,11 @@ use utoipa::{Modify, OpenApi}; AdminUserResponse, crate::routes::users::UpdateUserProfileRequest, crate::routes::users::UserStatusResponse, - // Temporary read-only Conversation migration-view models + // Temporary Conversation migration models BatchConversationsRequest, ConversationBatchResponse, ConversationObject, - ConversationItemList, - // Temporary read-only File migration-view models - FileUploadResponse, FileListResponse, + ConversationItemList, ConversationDeleteResult, + // Temporary File migration models + FileUploadResponse, FileListResponse, FileDeleteResponse, // Response models crate::routes::responses::StatelessCreateResponseRequestSchema, ResponseObject, // Attestation models diff --git a/crates/api/src/routes/conversations.rs b/crates/api/src/routes/conversations.rs index 65cdd7dba..8ad5dea66 100644 --- a/crates/api/src/routes/conversations.rs +++ b/crates/api/src/routes/conversations.rs @@ -14,13 +14,13 @@ use std::sync::Arc; use tracing::debug; use uuid::Uuid; -const CONVERSATION_WRITE_DISABLED_MESSAGE: &str = "The Conversations API is temporarily read-only while existing data remains available for export. This operation is no longer available. Only POST /v1/conversations/batch, GET /v1/conversations/{conversation_id}, and GET /v1/conversations/{conversation_id}/items are supported."; +const CONVERSATION_WRITE_DISABLED_MESSAGE: &str = "The Conversations API is temporarily limited while existing data remains available for export. This operation is no longer available. Only POST /v1/conversations/batch, GET /v1/conversations/{conversation_id}, GET /v1/conversations/{conversation_id}/items, and DELETE /v1/conversations/{conversation_id} are supported."; /// Return a stable migration response for Conversation mutations. /// -/// API-key authentication is enforced by the router. The read-only routes use -/// the same workspace-scoped service as before; this handler is only attached -/// to writes and unsupported legacy paths. +/// API-key authentication is enforced by the router. The temporary migration +/// routes use the same workspace-scoped service as before; this handler is +/// only attached to disabled writes and unsupported legacy paths. pub async fn conversation_write_disabled() -> (StatusCode, ResponseJson) { ( StatusCode::GONE, @@ -424,7 +424,9 @@ pub async fn update_conversation( ("conversation_id" = String, Path, description = "Conversation ID") ), responses( - (status = 200, description = "Conversation deleted successfully", body = ConversationDeleteResult), + (status = 200, description = "Conversation deleted successfully", body = ConversationDeleteResult, + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Conversation not found", body = ErrorResponse), diff --git a/crates/api/src/routes/files.rs b/crates/api/src/routes/files.rs index d7642cb81..79e61182e 100644 --- a/crates/api/src/routes/files.rs +++ b/crates/api/src/routes/files.rs @@ -17,14 +17,14 @@ pub const MAX_FILE_SIZE: usize = 512 * 1024 * 1024; // 512 MB /// Return a stable migration response for Files mutations. /// -/// Existing data remains available through the original read-only endpoints -/// while export tooling is in use. Upload and deletion requests do not reach -/// storage or repository layers. +/// Existing data remains available through the original migration endpoints +/// while export tooling is in use. Uploads and mutations other than the +/// existing per-file DELETE route do not reach storage or repository layers. pub async fn files_write_disabled() -> (StatusCode, Json) { ( StatusCode::GONE, Json(ErrorResponse::new( - "The Files API is temporarily read-only while existing data remains available for export. This operation is no longer available. Only GET /v1/files, GET /v1/files/{file_id}, and GET /v1/files/{file_id}/content are supported." + "The Files API is temporarily limited while existing data remains available for export. This operation is no longer available. Only GET /v1/files, GET /v1/files/{file_id}, GET /v1/files/{file_id}/content, and DELETE /v1/files/{file_id} are supported." .to_string(), "gone".to_string(), )), @@ -499,7 +499,9 @@ pub async fn get_file( ("file_id" = String, Path, description = "The ID of the file to delete") ), responses( - (status = 200, description = "File deleted successfully", body = FileDeleteResponse), + (status = 200, description = "File deleted successfully", body = FileDeleteResponse, + headers(("Cache-Control" = String, description = "Always no-store for confidential migration data")) + ), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "File not found", body = ErrorResponse) diff --git a/crates/api/tests/e2e_all/conversations.rs b/crates/api/tests/e2e_all/conversations.rs index 0df2b65fc..155ea7d07 100644 --- a/crates/api/tests/e2e_all/conversations.rs +++ b/crates/api/tests/e2e_all/conversations.rs @@ -24,11 +24,11 @@ fn assert_conversation_write_is_gone(response: axum_test::TestResponse) { error.error.code.as_deref(), Some("conversation_write_disabled") ); - assert!(error.error.message.contains("read-only")); + assert!(error.error.message.contains("temporarily limited")); } #[tokio::test] -async fn conversation_migration_views_require_an_api_key() { +async fn conversation_migration_routes_require_an_api_key() { let server = setup_test_server().await; assert_eq!( @@ -55,10 +55,17 @@ async fn conversation_migration_views_require_an_api_key() { .status_code(), 401 ); + assert_eq!( + server + .delete(&format!("/v1/conversations/{UNKNOWN_CONVERSATION_ID}")) + .await + .status_code(), + 401 + ); } #[tokio::test] -async fn conversation_migration_views_reach_read_handlers_and_are_no_store() { +async fn conversation_migration_routes_reach_workspace_scoped_handlers_and_are_no_store() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; @@ -87,23 +94,31 @@ async fn conversation_migration_views_reach_read_handlers_and_are_no_store() { ); assert_no_store(&response); } + + let delete = server + .delete(&format!("/v1/conversations/{UNKNOWN_CONVERSATION_ID}")) + .add_header("Authorization", format!("Bearer {api_key}")) + .await; + assert_eq!( + delete.status_code(), + 404, + "DELETE must reach its original workspace-scoped handler rather than the 410 fallback" + ); + assert_no_store(&delete); } #[tokio::test] -async fn conversation_writes_and_unlisted_reads_remain_gone_after_authentication() { +async fn all_conversation_mutations_except_delete_remain_gone_after_authentication() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; let routes = [ (Method::POST, "/v1/conversations"), (Method::GET, "/v1/conversations"), + (Method::DELETE, "/v1/conversations/batch"), ( Method::POST, "/v1/conversations/conv_00000000-0000-0000-0000-000000000000", ), - ( - Method::DELETE, - "/v1/conversations/conv_00000000-0000-0000-0000-000000000000", - ), ( Method::POST, "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/pin", @@ -132,6 +147,10 @@ async fn conversation_writes_and_unlisted_reads_remain_gone_after_authentication Method::PATCH, "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/unknown", ), + ( + Method::DELETE, + "/v1/conversations/conv_00000000-0000-0000-0000-000000000000/unknown", + ), ]; for (method, path) in routes { @@ -145,7 +164,7 @@ async fn conversation_writes_and_unlisted_reads_remain_gone_after_authentication } #[tokio::test] -async fn openapi_advertises_only_temporary_read_only_migration_views() { +async fn openapi_advertises_temporary_migration_routes_and_only_allowed_deletions() { let server = setup_test_server().await; let response = server.get("/api-docs/openapi.json").await; assert_eq!(response.status_code(), 200); @@ -158,15 +177,17 @@ async fn openapi_advertises_only_temporary_read_only_migration_views() { for (path, method) in [ ("/v1/conversations/batch", "post"), ("/v1/conversations/{conversation_id}", "get"), + ("/v1/conversations/{conversation_id}", "delete"), ("/v1/conversations/{conversation_id}/items", "get"), ("/v1/files", "get"), ("/v1/files/{file_id}", "get"), + ("/v1/files/{file_id}", "delete"), ("/v1/files/{file_id}/content", "get"), ] { let operation = paths.get(path).and_then(|path_item| path_item.get(method)); assert!( operation.is_some_and(serde_json::Value::is_object), - "OpenAPI must advertise temporary read-only migration view: {method} {path}" + "OpenAPI must advertise temporary migration route: {method} {path}" ); assert_eq!( operation.unwrap()["security"], @@ -182,13 +203,13 @@ async fn openapi_advertises_only_temporary_read_only_migration_views() { for (path, method) in [ ("/v1/conversations", "post"), ("/v1/conversations/{conversation_id}", "post"), - ("/v1/conversations/{conversation_id}", "delete"), ("/v1/conversations/{conversation_id}/pin", "post"), + ("/v1/conversations/{conversation_id}/pin", "delete"), ("/v1/conversations/{conversation_id}/archive", "post"), + ("/v1/conversations/{conversation_id}/archive", "delete"), ("/v1/conversations/{conversation_id}/clone", "post"), ("/v1/conversations/{conversation_id}/items", "post"), ("/v1/files", "post"), - ("/v1/files/{file_id}", "delete"), ] { assert!( paths @@ -209,8 +230,8 @@ async fn openapi_advertises_only_temporary_read_only_migration_views() { .and_then(|tag| tag["description"].as_str()) .unwrap_or_else(|| panic!("missing {tag_name} tag description")); assert!( - description.contains("Temporary authenticated, workspace-scoped read access"), - "{tag_name} must be documented as a temporary workspace-scoped read surface" + description.contains("Temporary authenticated, workspace-scoped migration access"), + "{tag_name} must be documented as a temporary workspace-scoped migration surface" ); assert!( description.contains("410 Gone"), @@ -230,8 +251,10 @@ async fn openapi_advertises_only_temporary_read_only_migration_views() { "ConversationItemList", "BatchConversationsRequest", "ConversationBatchResponse", + "ConversationDeleteResult", "FileUploadResponse", "FileListResponse", + "FileDeleteResponse", ] { assert!( schemas.contains_key(schema), @@ -242,8 +265,6 @@ async fn openapi_advertises_only_temporary_read_only_migration_views() { "CreateConversationRequest", "UpdateConversationRequest", "CreateConversationItemsRequest", - "ConversationDeleteResult", - "FileDeleteResponse", "ExpiresAfter", ] { assert!( diff --git a/crates/api/tests/e2e_all/files.rs b/crates/api/tests/e2e_all/files.rs index 1dfda0291..6978a615b 100644 --- a/crates/api/tests/e2e_all/files.rs +++ b/crates/api/tests/e2e_all/files.rs @@ -20,11 +20,11 @@ fn assert_file_write_is_gone(response: axum_test::TestResponse) { let error = response.json::(); assert_eq!(error.error.r#type, "gone"); - assert!(error.error.message.contains("read-only")); + assert!(error.error.message.contains("temporarily limited")); } #[tokio::test] -async fn file_migration_views_require_an_api_key() { +async fn file_migration_routes_require_an_api_key() { let server = setup_test_server().await; for path in [ @@ -38,10 +38,17 @@ async fn file_migration_views_require_an_api_key() { "temporary view must require an API key: {path}" ); } + assert_eq!( + server + .delete(&format!("/v1/files/{UNKNOWN_FILE_ID}")) + .await + .status_code(), + 401 + ); } #[tokio::test] -async fn file_migration_views_reach_read_handlers_and_are_no_store() { +async fn file_migration_routes_reach_workspace_scoped_handlers_and_are_no_store() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; @@ -67,21 +74,33 @@ async fn file_migration_views_reach_read_handlers_and_are_no_store() { ); assert_no_store(&response); } + + let delete = server + .delete(&format!("/v1/files/{UNKNOWN_FILE_ID}")) + .add_header("Authorization", format!("Bearer {api_key}")) + .await; + assert_eq!( + delete.status_code(), + 404, + "DELETE must reach its original workspace-scoped handler rather than the 410 fallback" + ); + assert_no_store(&delete); } #[tokio::test] -async fn file_writes_and_unlisted_subpaths_remain_gone_after_authentication() { +async fn all_file_mutations_except_delete_remain_gone_after_authentication() { let server = setup_test_server().await; let (api_key, _) = create_org_and_api_key(&server).await; let routes = [ (Method::POST, "/v1/files"), + (Method::DELETE, "/v1/files"), ( - Method::DELETE, + Method::PATCH, "/v1/files/file-00000000-0000-0000-0000-000000000000", ), ( - Method::PATCH, - "/v1/files/file-00000000-0000-0000-0000-000000000000", + Method::DELETE, + "/v1/files/file-00000000-0000-0000-0000-000000000000/content", ), (Method::PUT, "/v1/files/legacy/nested/path"), (Method::GET, "/v1/files/"), diff --git a/docs/local-development.md b/docs/local-development.md index 8972a1290..6dce21df7 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -273,8 +273,8 @@ Provider refresh runs every 300s by default | `GET /v1/models` | public | OpenAI-compatible model catalog with pricing metadata | | `POST /v1/chat/completions` | API key | OpenAI-compatible. Add `"stream": true` for SSE | | `POST /v1/responses` | API key | Stateless `store: false` inference; client-managed function tools only | -| `POST /v1/conversations/batch`, `GET /v1/conversations/{id}`, `GET /v1/conversations/{id}/items` | API key | Temporary workspace-scoped migration/export reads; all Conversation writes return `410` | -| `GET /v1/files`, `GET /v1/files/{id}`, `GET /v1/files/{id}/content` | API key | Temporary workspace-scoped migration/export reads; upload and all File writes return `410` | +| `POST /v1/conversations/batch`, `GET /v1/conversations/{id}`, `GET /v1/conversations/{id}/items`, `DELETE /v1/conversations/{id}` | API key | Temporary workspace-scoped migration routes; only per-conversation deletion remains enabled | +| `GET /v1/files`, `GET /v1/files/{id}`, `GET /v1/files/{id}/content`, `DELETE /v1/files/{id}` | API key | Temporary workspace-scoped migration routes; only per-file deletion remains enabled | | `POST /mcp` | API key | Independent MCP server exposing the `web_search` tool | | `GET /v1/attestation/report` | API key | TEE attestation (503 outside a CVM unless `DEV=true` in debug builds) | | `GET /v1/attestation/ita-token` | public | Intel Trust Authority JWT wrapper (requires ITA env vars) | @@ -322,16 +322,18 @@ which continues to expose its independent `web_search` tool, or the Responses itself rejects conversation linkage, response history, and file input. During the temporary migration/export window, the authenticated, -workspace-scoped read views remain available: +workspace-scoped migration routes remain available: -- `POST /v1/conversations/batch`, `GET /v1/conversations/{id}`, and - `GET /v1/conversations/{id}/items`; -- `GET /v1/files`, `GET /v1/files/{id}`, and `GET /v1/files/{id}/content`. +- `POST /v1/conversations/batch`, `GET /v1/conversations/{id}`, + `GET /v1/conversations/{id}/items`, and `DELETE /v1/conversations/{id}`; +- `GET /v1/files`, `GET /v1/files/{id}`, `GET /v1/files/{id}/content`, and + `DELETE /v1/files/{id}`. -These views preserve existing retrieval behavior only; they are not a new +These routes preserve existing retrieval behavior and keep per-resource +deletion available to support existing account deletion; they are not a new export API or a new Conversation-list endpoint. They send `Cache-Control: -no-store`. Creation, upload, deletion, pinning, archiving, cloning, item -creation, and every other Conversation or File mutation return `410 Gone`. +no-store`. Creation, upload, pinning, archiving, cloning, item creation, and +every other Conversation or File mutation return `410 Gone`. ## 7. Troubleshooting From 59a96a882ad718857550262d212aedabbc9f93f7 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:57:30 +0800 Subject: [PATCH 30/31] docs(api): align Stage I architecture diagrams --- docs/architecture/c4-diagrams.md | 189 ++++++++++++++++--------------- 1 file changed, 96 insertions(+), 93 deletions(-) diff --git a/docs/architecture/c4-diagrams.md b/docs/architecture/c4-diagrams.md index 3cbe88fb6..ae0825e92 100644 --- a/docs/architecture/c4-diagrams.md +++ b/docs/architecture/c4-diagrams.md @@ -136,8 +136,8 @@ graph TB WorkspaceRoutes["Workspace Routes
Workspace & API key
management"] UserRoutes["User Routes
Profile, invitations,
sessions"] CompletionRoutes["Completion Routes
Chat & text
completions"] - ConvRoutes["Conversation Routes
Create & manage
conversations"] - ResponseRoutes["Response Routes
Streaming AI
responses"] + ConvRoutes["Conversation Routes
Temporary migration
views and deletion"] + ResponseRoutes["Response Routes
Stateless one-shot
AI responses"] ModelRoutes["Model Routes
List available
models"] UsageRoutes["Usage Routes
Tracking & billing
data"] AttestationRoutes["Attestation Routes
TEE verification
& signatures"] @@ -152,8 +152,8 @@ graph TB AuthService["Auth Service
Authentication
& authorization"] OrgService["Organization Service
Multi-tenant
management"] UserService["User Service
User & session
management"] - ConvService["Conversation Service
Conversation
lifecycle"] - ResponseService["Response Service
AI completion
orchestration"] + ConvService["Conversation Service
Temporary migration
views and deletion"] + ResponseService["Response Service
Stateless one-shot
Responses compatibility"] CompletionService["Completion Service
Model inference
coordination"] ModelService["Model Service
Model catalog
& pricing"] UsageService["Usage Service
Usage tracking
& limits"] @@ -168,7 +168,7 @@ graph TB SessionRepo["Session Repository"] APIKeyRepo["API Key Repository"] ConvRepo["Conversation Repository"] - ResponseRepo["Response Repository"] + ResponseRepo["Legacy Response Repository
Retained history data"] ModelRepo["Model Repository"] UsageRepo["Usage Repository"] AttestationRepo["Attestation Repository"] @@ -181,7 +181,7 @@ graph TB end %% External Containers - Database[("PostgreSQL Database
[Container: PostgreSQL 16]

Stores:
• Organizations & workspaces
• Users & sessions
• Conversations & responses
• Usage & billing data
• API keys
• Chat signatures")] + Database[("PostgreSQL Database
[Container: PostgreSQL 16]

Stores:
• Organizations & workspaces
• Users & sessions
• Legacy conversations & responses
• Usage & billing data
• API keys
• Chat signatures")] GitHubAuth["GitHub OAuth
[External API]

OAuth 2.0
authentication"] @@ -238,7 +238,6 @@ graph TB %% Service Dependencies ResponseService --> CompletionService - ResponseService --> ConvService ResponseService --> UsageService CompletionService --> ProviderPool CompletionService --> ModelService @@ -257,7 +256,6 @@ graph TB UserService --> UserRepo UserService --> SessionRepo ConvService --> ConvRepo - ResponseService --> ResponseRepo ModelService --> ModelRepo UsageService --> UsageRepo AttestationService --> AttestationRepo @@ -312,8 +310,8 @@ graph TB | **Workspace Routes** | Workspace and API key management | Session (OAuth) | | **User Routes** | User profile, invitations, sessions | Session (OAuth) | | **Completion Routes** | Chat & text completions (OpenAI-compatible) | API Key | -| **Conversation Routes** | Conversation lifecycle management | API Key | -| **Response Routes** | AI response requests (streaming/non-streaming) | API Key | +| **Conversation Routes** | Temporary known-ID views and per-resource deletion; other paths return `410` | API Key | +| **Response Routes** | Stateless one-shot inference; response history returns `410` | API Key | | **Model Routes** | List available models | API Key | | **Usage Routes** | Usage tracking, billing, limits | Session (OAuth) | | **Attestation Routes** | TEE attestation reports, chat signatures | API Key | @@ -324,8 +322,8 @@ graph TB | **Auth Service** | Session & API key validation, OAuth integration | User Repo, Session Repo, API Key Repo, OAuth Providers | | **Organization Service** | Multi-tenant organization & workspace management | Organization Repo, Workspace Repo | | **User Service** | User management, profile updates | User Repo, Session Repo | -| **Conversation Service** | Conversation creation & retrieval | Conversation Repo | -| **Response Service** | Orchestrates AI completion requests | Completion Service, Usage Service, Response Repo | +| **Conversation Service** | Temporary workspace-scoped views and per-resource deletion | Conversation Repo | +| **Response Service** | One Chat Completions request per stateless Responses call | Completion Service, Usage Service | | **Completion Service** | Coordinates with inference providers | Provider Pool, Model Service | | **Model Service** | Manages model catalog & pricing | Model Repo | | **Usage Service** | Tracks token usage, enforces limits | Usage Repo, Organization Repo | @@ -340,8 +338,8 @@ graph TB | **Workspace Repository** | workspaces | Workspace management | | **Session Repository** | sessions | Session creation, validation, cleanup | | **API Key Repository** | api_keys | API key creation, validation, revocation | -| **Conversation Repository** | conversations | Conversation storage & retrieval | -| **Response Repository** | responses | AI response storage & history | +| **Conversation Repository** | conversations | Temporary migration views and account-cleanup deletion | +| **Legacy Response Repository** | responses | Retained historical response data; not used by stateless Responses | | **Model Repository** | models, model_pricing | Model catalog & pricing data | | **Usage Repository** | Various usage tracking tables | Usage tracking, billing calculations | | **Attestation Repository** | chat_signatures | Cryptographic signatures & attestation | @@ -492,37 +490,29 @@ sequenceDiagram Note over Client,Database: Streaming provides real-time response
while tracking usage ``` -### 4. Response Creation Flow (Platform-specific API) +### 4. Stateless Response Creation Flow (Platform-specific API) -Creating an AI response linked to a conversation using the platform-specific API. +`POST /v1/responses` is a stateless compatibility API. Each successful request +creates one Chat Completions inference request; it does not link to a +Conversation or persist response/item history. ```mermaid sequenceDiagram actor Client participant PlatformAPI participant ResponseService - participant ConversationService participant CompletionService - participant Database participant vLLM + participant AttestationService - Client->>PlatformAPI: POST /v1/responses
{ model: "llama-3", conversation_id: "conv_xxx", input: {...} } + Client->>PlatformAPI: POST /v1/responses
{ model: "llama-3", store: false, input: {...} } - PlatformAPI->>PlatformAPI: Validate API key (middleware) + PlatformAPI->>PlatformAPI: Validate API key and stateless fields PlatformAPI->>ResponseService: create_response_stream(request) - - ResponseService->>Database: INSERT responses
(status: in_progress) - Database-->>ResponseService: response_id - ResponseService-->>Client: SSE: response.created
{ id: "resp_xxx", status: "in_progress" } - alt conversation_id provided - ResponseService->>ConversationService: Get conversation - ConversationService->>Database: SELECT conversation - Database-->>ConversationService: Conversation record - ResponseService->>ResponseService: Append to conversation context - end + ResponseService->>ResponseService: Build request-scoped message context ResponseService->>CompletionService: create_completion_stream() CompletionService->>vLLM: POST /v1/chat/completions @@ -536,61 +526,56 @@ sequenceDiagram vLLM-->>CompletionService: Completion done + usage CompletionService-->>ResponseService: Complete + usage - ResponseService->>Database: UPDATE responses
(status: completed, output_message, usage) - Database-->>ResponseService: Updated + opt Completed-response attestation succeeds + ResponseService->>AttestationService: Store response ID + digest signatures + end ResponseService-->>Client: SSE: response.completed
{ status: "completed", usage: {...} } - Note over Client,Database: Response is stored and linked
to conversation for history + Note over Client,AttestationService: No responses/response_items row, response history,
server tool execution, or agent loop is used ``` -### 5. External Function Call Flow (Tool Use) +### 5. Client-Managed Function Call Flow (Tool Use) -Multi-turn flow where the LLM requests an external function, the client executes -it, and resumes the response with the result. This covers custom functions, -code_interpreter, and computer tools (all client-executed). +Multi-turn flow where the model requests a custom function and the client +executes it. Only `type: "function"` tools are accepted by Responses; +server-executed/builtin tools are rejected. ```mermaid sequenceDiagram actor Client participant PlatformAPI participant ResponseService - participant Database + participant CompletionService + participant vLLM - Note over Client,Database: Turn 1 - LLM requests a function call + Note over Client,vLLM: Turn 1 - model requests a client-managed function Client->>PlatformAPI: POST /v1/responses
{ model, input, tools: [{ type: "function", name: "get_weather", ... }] } PlatformAPI->>ResponseService: create_response_stream(request) - ResponseService->>Database: INSERT response (status: in_progress) - ResponseService-->>Client: SSE: response.created - - Note over ResponseService: LLM emits a tool_call for "get_weather" - - ResponseService->>Database: INSERT response_item (FunctionCall)
call_id is unique per call (generated if LLM omits it) + ResponseService->>CompletionService: One Chat Completions inference + CompletionService->>vLLM: POST /v1/chat/completions + vLLM-->>CompletionService: Function call for "get_weather" + CompletionService-->>ResponseService: Function call ResponseService-->>Client: SSE: response.output_item.added (FunctionCall)
{ call_id, name: "get_weather", arguments: "..." } - ResponseService->>Database: UPDATE response (status: incomplete) ResponseService-->>Client: SSE: response.incomplete
{ reason: "function_call_required" } - Note over Client,Database: Turn 2 - Client provides function output + Client->>Client: Execute get_weather and retain its own transcript - Client->>PlatformAPI: POST /v1/responses
{ model, previous_response_id: "resp_xxx",
input: [{ type: "function_call_output", call_id, output: "72°F" }] } - PlatformAPI->>ResponseService: create_response_stream(request) - - ResponseService->>Database: SELECT response WHERE id = resp_xxx AND workspace_id = caller's workspace - Note over ResponseService: Workspace ownership verified (prevents IDOR) - - ResponseService->>Database: SELECT response_items WHERE response_id = resp_xxx - Note over ResponseService: Validate each call_id matches exactly one FunctionCall - - ResponseService->>Database: INSERT new response (status: in_progress) - ResponseService-->>Client: SSE: response.created - - Note over ResponseService: Resume inference with function result in context + Note over Client,vLLM: Turn 2 - new stateless request with replayed context + Client->>PlatformAPI: POST /v1/responses
{ model, store: false, tools, input: [...history,
{ type: "function_call", call_id, name, arguments },
{ type: "function_call_output", call_id, output: "72°F" }] } + PlatformAPI->>ResponseService: create_response_stream(request) + ResponseService->>ResponseService: Map supplied replay into this request's provider context + ResponseService->>CompletionService: One Chat Completions inference + CompletionService->>vLLM: POST /v1/chat/completions + vLLM-->>CompletionService: Completion + CompletionService-->>ResponseService: Completion ResponseService-->>Client: SSE: response.output_text.delta - ResponseService->>Database: UPDATE response (status: completed) ResponseService-->>Client: SSE: response.completed + + Note over Client,vLLM: Cloud never executes the function, looks up history,
or starts an agent loop; arguments are replayed unchanged ``` ### 6. Model Discovery Flow @@ -777,47 +762,43 @@ sequenceDiagram Note over UsageMiddleware,Database: Usage tracked per request
for billing and analytics ``` -### 10. Response Lifecycle State Diagram +### 10. Stateless Response Request Lifecycle -State transitions for AI response objects throughout their lifecycle. +Request-scoped states emitted while serving a single stateless Responses +request. These are not durable response-history states. ```mermaid stateDiagram-v2 - [*] --> in_progress: POST /v1/responses + [*] --> in_progress: POST /v1/responses (store: false) in_progress --> completed: Inference successful - in_progress --> incomplete: Function call required + in_progress --> incomplete: Client-managed function call required in_progress --> failed: Inference error - in_progress --> cancelled: User cancels
(POST /responses/{id}/cancel) - incomplete --> in_progress: Client submits FunctionCallOutput
(POST /v1/responses with previous_response_id) + incomplete --> [*]: Client executes the function - completed --> [*]: Response stored - incomplete --> [*]: Client does not resume - failed --> [*]: Error logged - cancelled --> [*]: Marked cancelled + completed --> [*]: Response delivered, not stored + failed --> [*]: Error delivered note right of in_progress - Streaming tokens to client - Tracking usage + One Chat Completions inference + Streaming tokens and tracking usage end note note right of incomplete - LLM requested external function call - FunctionCall items stored with unique call_ids - Waiting for client to execute and resume + Model requested a custom function + Client owns execution and transcript + A later request replays the call and output end note note right of completed - Final response stored - Usage recorded - Conversation updated + No response/item history is written + Best-effort digest signature may be stored end note note right of failed - Error details saved - Partial usage tracked - Client notified + No server-side continuation or cancel path + Client receives the failure end note ``` @@ -1166,6 +1147,12 @@ Client → GET /attestation → Cloud API ## Data Model Overview +> Stage I migration note: the Conversation and Response tables shown below are +> legacy data retained for the export window. Stateless `POST /v1/responses` +> does not create, read, or update `responses` or `response_items` rows, and +> response-history routes return `410 Gone`. The retained Conversation/File +> views and their underlying wiring are removed only in Stage III. + ### Core Entities ```mermaid @@ -1411,18 +1398,35 @@ erDiagram - `GET /v1/model/{model_name}` - Get model details with pricing (public) **Conversations:** -- `POST /v1/conversations` - Create conversation -- `GET /v1/conversations/{id}` - Get conversation -- `POST /v1/conversations/{id}` - Update conversation -- `DELETE /v1/conversations/{id}` - Delete conversation -- `GET /v1/conversations/{id}/items` - List conversation items +- `POST /v1/conversations/batch` - Retrieve known conversations for migration/export +- `GET /v1/conversations/{id}` - Get a workspace-scoped conversation +- `GET /v1/conversations/{id}/items` - List a workspace-scoped conversation's items +- `DELETE /v1/conversations/{id}` - Retained normal deletion for account cleanup +- All other Conversation methods and legacy descendants - Authenticated `410 Gone` + +**Files:** +- `GET /v1/files` - List workspace-scoped files for migration/export +- `GET /v1/files/{id}` - Get workspace-scoped file metadata +- `GET /v1/files/{id}/content` - Get workspace-scoped file content +- `DELETE /v1/files/{id}` - Retained normal deletion for account cleanup +- All other File methods and legacy descendants - Authenticated `410 Gone` + +> The temporary Conversation/File views and retained per-resource deletes use +> API-key/workspace authorization and `Cache-Control: no-store`. They are not +> new export or Conversation-list APIs; the surfaces are removed in Stage III +> after the migration/export window and account-deletion lifecycle. **Responses (Platform-specific):** -- `POST /v1/responses` - Create AI response (streaming/non-streaming) -- `GET /v1/responses/{id}` - Get response details -- `DELETE /v1/responses/{id}` - Delete response -- `POST /v1/responses/{id}/cancel` - Cancel in-progress response -- `GET /v1/responses/{id}/input_items` - List input items +- `POST /v1/responses` - Stateless `store: false` inference; one Chat Completions call +- `GET`/`DELETE /v1/responses/{id}` - Authenticated `410 Gone` (history retired) +- `POST /v1/responses/{id}/cancel` - Authenticated `410 Gone` (history retired) +- `GET /v1/responses/{id}/input_items` - Authenticated `410 Gone` (history retired) + +> Responses accepts only custom client-managed `function` tools. Clients keep +> history and replay a raw `function_call` plus its matching +> `function_call_output` in a fresh request; Cloud neither executes tools nor +> runs an agent loop. Responses, including retired-history responses, use +> `Cache-Control: no-store`. **Attestation:** - `GET /v1/signature/{chat_id}` - Get chat signature @@ -1474,4 +1478,3 @@ For additional details, see: - API documentation: OpenAPI/Swagger UI (when server is running) - Service interfaces: `/crates/services/src/*/ports.rs` - Configuration: `/config/config.yaml` - From a3cb82f6fbaff4e77234221ddfaee8a43291be4b Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:01:22 +0800 Subject: [PATCH 31/31] fix(responses): preserve aliases and assistant replay --- crates/api/tests/e2e_all/function_tools.rs | 16 ++-- .../api/tests/e2e_all/responses_stateless.rs | 10 ++- crates/services/src/completions/mod.rs | 7 +- crates/services/src/completions/ports.rs | 3 +- crates/services/src/responses/service.rs | 73 +++++++++++++++---- 5 files changed, 80 insertions(+), 29 deletions(-) diff --git a/crates/api/tests/e2e_all/function_tools.rs b/crates/api/tests/e2e_all/function_tools.rs index d7e92c93c..8f40c740a 100644 --- a/crates/api/tests/e2e_all/function_tools.rs +++ b/crates/api/tests/e2e_all/function_tools.rs @@ -130,18 +130,13 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor let second = second.json::(); assert_eq!(second["status"], "completed"); - // The provider receives the raw assistant output text, then a standard - // assistant-tool-call and the client-produced tool result; Cloud did not - // execute the custom function. + // The provider receives the raw assistant output text and replayed custom + // tool call in one assistant turn, followed by the client-produced tool + // result. Cloud did not execute the custom function. let params = mock .last_chat_params() .await .expect("second request reached provider"); - assert!(params.messages.iter().any(|message| { - message.role == MessageRole::Assistant - && message.tool_calls.is_none() - && message.content.as_ref() == Some(&serde_json::json!("I will look that up.")) - })); let assistant = params .messages .iter() @@ -149,6 +144,11 @@ async fn stateless_function_call_is_replayed_by_the_client_without_server_histor message.role == MessageRole::Assistant && message.tool_calls.as_ref().is_some() }) .expect("provider received replayed assistant tool call"); + assert_eq!( + assistant.content.as_ref(), + Some(&serde_json::json!("I will look that up.")), + "assistant output text and its function call must remain one provider turn" + ); let tool_calls = assistant.tool_calls.as_ref().expect("tool calls present"); assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].id.as_deref(), Some(call_id.as_str())); diff --git a/crates/api/tests/e2e_all/responses_stateless.rs b/crates/api/tests/e2e_all/responses_stateless.rs index fb2000f7f..8bff83499 100644 --- a/crates/api/tests/e2e_all/responses_stateless.rs +++ b/crates/api/tests/e2e_all/responses_stateless.rs @@ -185,17 +185,18 @@ async fn stateless_responses_reject_unsupported_tool_types_during_deserializatio } #[tokio::test] -async fn stateless_responses_reject_image_output_models_before_provider_work() { +async fn stateless_responses_reject_image_output_model_aliases_before_provider_work() { let (server, _pool, mock, _database) = setup_test_server_with_pool().await; // Configure the capability explicitly instead of relying on the catalog // defaults for the model name. Responses must remain a text-completion // wrapper and reject image-generation models before asking a provider to // do any work. - let model = "Qwen/Qwen-Image-2512"; + let model = format!("responses-image-output-model-{}", uuid::Uuid::new_v4()); + let model_alias = format!("responses-image-output-alias-{}", uuid::Uuid::new_v4()); let mut batch = BatchUpdateModelApiRequest::new(); batch.insert( - model.to_string(), + model, serde_json::from_value(serde_json::json!({ "inputCostPerToken": { "amount": 0, "currency": "USD" }, "outputCostPerToken": { "amount": 0, "currency": "USD" }, @@ -206,6 +207,7 @@ async fn stateless_responses_reject_image_output_models_before_provider_work() { "maxOutputLength": 1024, "verifiable": true, "isActive": true, + "aliases": [model_alias.clone()], "inputModalities": ["text"], "outputModalities": ["image"] })) @@ -234,7 +236,7 @@ async fn stateless_responses_reject_image_output_models_before_provider_work() { .post("/v1/responses") .add_header("Authorization", format!("Bearer {api_key}")) .json(&serde_json::json!({ - "model": model, + "model": model_alias, "input": "Draw a small red square.", "store": false, "stream": false, diff --git a/crates/services/src/completions/mod.rs b/crates/services/src/completions/mod.rs index cc5e39e46..ec672579e 100644 --- a/crates/services/src/completions/mod.rs +++ b/crates/services/src/completions/mod.rs @@ -2237,7 +2237,12 @@ impl ports::CompletionServiceTrait for CompletionServiceImpl { &self, model_name: &str, ) -> Result, anyhow::Error> { - self.models_repository.get_model_by_name(model_name).await + // Match the model resolution used by the completion path. Capability + // guards that run before dispatch (such as Responses rejecting image + // output) must apply to aliases as well as canonical model names. + self.models_repository + .resolve_and_get_model(model_name) + .await } fn get_inference_provider_pool( diff --git a/crates/services/src/completions/ports.rs b/crates/services/src/completions/ports.rs index 0ecec2f21..dca86329e 100644 --- a/crates/services/src/completions/ports.rs +++ b/crates/services/src/completions/ports.rs @@ -241,7 +241,8 @@ pub trait CompletionServiceTrait: Send + Sync { params: inference_providers::ScoreParams, ) -> Result; - /// Get model information by name (for checking output_modalities, etc.) + /// Get model information by canonical name or active alias (for checking + /// output modalities and other model capabilities). async fn get_model( &self, model_name: &str, diff --git a/crates/services/src/responses/service.rs b/crates/services/src/responses/service.rs index 7e8823b3c..6bc115c72 100644 --- a/crates/services/src/responses/service.rs +++ b/crates/services/src/responses/service.rs @@ -1528,22 +1528,33 @@ impl ResponseServiceImpl { } /// Flush replayed function calls into the assistant message shape expected - /// by Chat Completions providers. The calls remain entirely client-owned: - /// this only reconstructs supplied context for the current request. + /// by Chat Completions providers. When a client replays an assistant + /// message followed immediately by its `function_call` items, those + /// are one logical assistant turn in the provider transcript. The calls + /// remain entirely client-owned: this only reconstructs supplied context + /// for the current request without validating or reordering it. fn flush_replayed_function_calls( messages: &mut Vec, + pending_assistant_message: &mut Option, pending_function_calls: &mut Vec, ) { if pending_function_calls.is_empty() { + if let Some(message) = pending_assistant_message.take() { + messages.push(message); + } return; } - messages.push(crate::completions::ports::CompletionMessage { - role: "assistant".to_string(), - content: serde_json::Value::String(String::new()), - tool_call_id: None, - tool_calls: Some(std::mem::take(pending_function_calls)), + let mut message = pending_assistant_message.take().unwrap_or_else(|| { + crate::completions::ports::CompletionMessage { + role: "assistant".to_string(), + content: serde_json::Value::String(String::new()), + tool_call_id: None, + tool_calls: None, + } }); + message.tool_calls = Some(std::mem::take(pending_function_calls)); + messages.push(message); } /// Append one client-replayed function item to the request's completion @@ -1551,6 +1562,7 @@ impl ResponseServiceImpl { fn append_replayed_function_call_item( input_item: &models::ResponseInputItem, messages: &mut Vec, + pending_assistant_message: &mut Option, pending_function_calls: &mut Vec, ) -> bool { match input_item { @@ -1572,7 +1584,11 @@ impl ResponseServiceImpl { models::ResponseInputItem::FunctionCallOutput { call_id, output, .. } => { - Self::flush_replayed_function_calls(messages, pending_function_calls); + Self::flush_replayed_function_calls( + messages, + pending_assistant_message, + pending_function_calls, + ); messages.push(crate::completions::ports::CompletionMessage { role: "tool".to_string(), content: serde_json::Value::String(output.clone()), @@ -1985,10 +2001,12 @@ impl ResponseServiceImpl { } models::ResponseInput::Items(items) => { let mut pending_function_calls = Vec::new(); + let mut pending_assistant_message = None; for item in items { if Self::append_replayed_function_call_item( item, &mut messages, + &mut pending_assistant_message, &mut pending_function_calls, ) { continue; @@ -1996,10 +2014,6 @@ impl ResponseServiceImpl { match item { models::ResponseInputItem::Message { role, content, .. } => { - Self::flush_replayed_function_calls( - &mut messages, - &mut pending_function_calls, - ); let content = match content { models::ResponseContent::Text(text) => { serde_json::Value::String(text.clone()) @@ -2013,12 +2027,31 @@ impl ResponseServiceImpl { .await? } }; - messages.push(CompletionMessage { + let message = CompletionMessage { role: role.clone(), content, tool_call_id: None, tool_calls: None, - }); + }; + // An assistant message immediately before + // replayed function calls belongs to the same + // provider assistant turn. Other roles remain + // individual messages in caller-supplied order. + if role == "assistant" { + Self::flush_replayed_function_calls( + &mut messages, + &mut pending_assistant_message, + &mut pending_function_calls, + ); + pending_assistant_message = Some(message); + } else { + Self::flush_replayed_function_calls( + &mut messages, + &mut pending_assistant_message, + &mut pending_function_calls, + ); + messages.push(message); + } } models::ResponseInputItem::McpApprovalResponse { .. } | models::ResponseInputItem::McpListTools { .. } @@ -2026,7 +2059,11 @@ impl ResponseServiceImpl { | models::ResponseInputItem::FunctionCallOutput { .. } => {} } } - Self::flush_replayed_function_calls(&mut messages, &mut pending_function_calls); + Self::flush_replayed_function_calls( + &mut messages, + &mut pending_assistant_message, + &mut pending_function_calls, + ); } } } @@ -2449,16 +2486,19 @@ mod tests { ]; let mut messages = Vec::new(); + let mut pending_assistant_message = None; let mut pending_function_calls = Vec::new(); for item in &items { assert!(ResponseServiceImpl::append_replayed_function_call_item( item, &mut messages, + &mut pending_assistant_message, &mut pending_function_calls, )); } ResponseServiceImpl::flush_replayed_function_calls( &mut messages, + &mut pending_assistant_message, &mut pending_function_calls, ); @@ -2618,16 +2658,19 @@ mod tests { ]; let mut messages = Vec::new(); + let mut pending_assistant_message = None; let mut pending_function_calls = Vec::new(); for item in &items { assert!(ResponseServiceImpl::append_replayed_function_call_item( item, &mut messages, + &mut pending_assistant_message, &mut pending_function_calls, )); } ResponseServiceImpl::flush_replayed_function_calls( &mut messages, + &mut pending_assistant_message, &mut pending_function_calls, );