diff --git a/crates/api/src/consts.rs b/crates/api/src/consts.rs index 1e6cc400..55954bb9 100644 --- a/crates/api/src/consts.rs +++ b/crates/api/src/consts.rs @@ -10,9 +10,6 @@ pub const MAX_EMAIL_LENGTH: usize = 255; /// Maximum length for organization/system prompts pub const MAX_SYSTEM_PROMPT_LENGTH: usize = 64 * 1024; -/// Maximum serialized size for small metadata blobs (e.g. conversation metadata) -pub const MAX_METADATA_SIZE_BYTES: usize = 16 * 1024; - /// Maximum serialized size for settings / larger JSON blobs pub const MAX_SETTINGS_SIZE_BYTES: usize = 32 * 1024; diff --git a/crates/api/src/models.rs b/crates/api/src/models.rs index 83525171..72c69d3b 100644 --- a/crates/api/src/models.rs +++ b/crates/api/src/models.rs @@ -4,8 +4,48 @@ use serde_json::Value; use std::collections::HashMap; use utoipa::ToSchema; -// Re-export ResponseImageUrl from services to avoid duplication -pub use services::responses::models::ResponseImageUrl; +// Re-export types from services to avoid duplication +// These are the canonical definitions used across the codebase +pub use services::responses::models::{ + ConversationDeleteResult, + ConversationObject, + ConversationReference, + // Conversation types + CreateConversationRequest, + // Request/Response types used by routes and OpenAPI + CreateResponseRequest, + McpApprovalMode, + // MCP types + McpApprovalRequirement, + McpDiscoveredTool, + McpToolNameFilter, + // Response content types (shared between input listing and services) + ResponseContent, + ResponseContentItem, + ResponseContentPart, + ResponseDeleteResult, + ResponseError, + // Supporting types used by the above + ResponseImageUrl, + ResponseIncompleteDetails, + ResponseInput, + ResponseItemStatus, + ResponseObject, + ResponseOutputContent, + ResponseOutputFunction, + // Response output types + ResponseOutputItem, + ResponseOutputToolCall, + ResponseReasoningConfig, + ResponseReasoningOutput, + ResponseStatus, + ResponseStreamEvent, + ResponseTool, + ResponseToolChoice, + ResponseToolChoiceFunction, + ResponseToolChoiceOutput, + UpdateConversationRequest, +}; // Streaming response models #[derive(Debug, Serialize, Deserialize)] @@ -761,8 +801,8 @@ fn default_n() -> Option { // ============================================ use crate::consts::{ - MAX_DESCRIPTION_LENGTH, MAX_EMAIL_LENGTH, MAX_INVITATIONS_PER_REQUEST, MAX_METADATA_SIZE_BYTES, - MAX_NAME_LENGTH, MAX_SETTINGS_SIZE_BYTES, MAX_SYSTEM_PROMPT_LENGTH, + MAX_DESCRIPTION_LENGTH, MAX_EMAIL_LENGTH, MAX_INVITATIONS_PER_REQUEST, MAX_NAME_LENGTH, + MAX_SETTINGS_SIZE_BYTES, MAX_SYSTEM_PROMPT_LENGTH, }; use crate::routes::common::{validate_max_length, validate_non_empty_field}; @@ -900,66 +940,12 @@ impl ErrorResponse { } // ============================================ -// Response API Models +// Response API Models (API-specific types for input listing) +// Note: Most response types are re-exported from services::responses::models // ============================================ -/// Request to create a response -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateResponseRequest { - 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 conversation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub previous_response_id: 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, - #[serde(skip_serializing_if = "Option::is_none")] - pub store: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub background: 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 text: 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")] - pub signing_algo: Option, -} - -/// Input for a response - can be text, array of items, or single item -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum ResponseInput { - Text(String), - Items(Vec), -} - -/// Single input item +/// Single input item for API responses (simplified struct version). +/// Used by list_input_items endpoint for returning input items. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ResponseInputItem { pub role: String, @@ -968,417 +954,6 @@ pub struct ResponseInputItem { pub metadata: Option, } -/// Content can be text or array of content parts -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum ResponseContent { - Text(String), - Parts(Vec), -} - -/// Content part from user inputs (input-only variants). -/// -/// This type is used for type-safe operations on user inputs only. -/// It cannot contain output variants, providing compile-time safety. -/// -/// Used in: -/// - ResponseContent::Parts (for input listing) -/// - list_input_items endpoint -/// - Input validation operations -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "type")] -pub enum ResponseContentPart { - #[serde(rename = "input_text")] - InputText { text: String }, - #[serde(rename = "input_image")] - InputImage { - image_url: ResponseImageUrl, - #[serde(skip_serializing_if = "Option::is_none")] - detail: Option, - }, - #[serde(rename = "input_file")] - InputFile { - file_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - detail: Option, - }, -} - -/// Conversation reference -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum ConversationReference { - Id(String), - Object { - id: String, - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option, - }, -} - -/// Tool configuration for responses -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "type")] -pub enum ResponseTool { - #[serde(rename = "function")] - Function { - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - parameters: Option, - }, - #[serde(rename = "web_search")] - WebSearch {}, - #[serde(rename = "file_search")] - FileSearch {}, - #[serde(rename = "code_interpreter")] - CodeInterpreter {}, - #[serde(rename = "computer")] - Computer {}, - #[serde(rename = "mcp")] - Mcp { - server_label: String, - server_url: String, - #[serde(skip_serializing_if = "Option::is_none")] - server_description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - authorization: Option, - #[serde(default)] - require_approval: McpApprovalRequirement, - #[serde(skip_serializing_if = "Option::is_none")] - allowed_tools: Option>, - }, -} - -/// Approval requirement for MCP tool calls -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum McpApprovalRequirement { - Simple(McpApprovalMode), - Granular { never: McpToolNameFilter }, -} - -impl Default for McpApprovalRequirement { - fn default() -> Self { - Self::Simple(McpApprovalMode::Always) - } -} - -/// Simple MCP approval mode -#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum McpApprovalMode { - #[default] - Always, - Never, -} - -/// Filter for tool names that don't require approval -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct McpToolNameFilter { - pub tool_names: std::collections::HashSet, -} - -/// Tool choice configuration -#[derive(Debug, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum ResponseToolChoice { - Auto(String), // "auto", "none", "required" - Specific { - #[serde(rename = "type")] - type_: String, - function: ResponseToolChoiceFunction, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseToolChoiceFunction { - pub name: String, -} - -/// Text format configuration -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseTextConfig { - pub format: ResponseTextFormat, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "type")] -pub enum ResponseTextFormat { - #[serde(rename = "text")] - Text, - #[serde(rename = "json_object")] - JsonObject, - #[serde(rename = "json_schema")] - JsonSchema { json_schema: serde_json::Value }, -} - -/// Reasoning configuration -#[derive(Debug, Deserialize, ToSchema)] -pub struct ResponseReasoningConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, -} - -/// Complete response object -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseObject { - pub id: String, - pub object: String, // "response" - pub created_at: i64, - pub status: ResponseStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub incomplete_details: 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, - pub model: String, - pub output: Vec, - pub parallel_tool_calls: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub previous_response_id: Option, // Previous response ID (parent in thread) - #[serde(default)] - pub next_response_ids: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning: Option, - pub store: bool, - pub temperature: f32, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - pub tool_choice: ResponseToolChoiceOutput, - pub tools: Vec, - pub top_p: f32, - pub truncation: String, - pub usage: Usage, - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ResponseStatus { - Completed, - Failed, - InProgress, - Cancelled, - Queued, - Incomplete, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseError { - pub message: String, - #[serde(rename = "type")] - pub type_: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseIncompleteDetails { - pub reason: String, // "length", "content_filter", "max_tool_calls" -} - -/// Output item from response -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "type")] -pub enum ResponseOutputItem { - #[serde(rename = "message")] - Message { - id: String, - status: ResponseItemStatus, - role: String, - content: Vec, - }, - #[serde(rename = "tool_call")] - ToolCall { - id: String, - status: ResponseItemStatus, - tool_type: String, - function: ResponseOutputFunction, - }, - #[serde(rename = "reasoning")] - Reasoning { - id: String, - status: ResponseItemStatus, - summary: String, - content: String, - }, - #[serde(rename = "mcp_list_tools")] - McpListTools { - id: String, - server_label: String, - tools: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - }, - #[serde(rename = "mcp_approval_request")] - McpApprovalRequest { - id: String, - #[serde(default)] - response_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - previous_response_id: Option, - #[serde(default)] - next_response_ids: Vec, - #[serde(default)] - created_at: i64, - server_label: String, - name: String, - arguments: String, - model: String, - }, - /// External function call requiring client execution. - #[serde(rename = "function_call")] - FunctionCall { - /// Unique identifier for this function call item. - id: String, - /// The response ID this item belongs to. - #[serde(default)] - response_id: String, - /// The previous response ID if this is a continuation. - #[serde(skip_serializing_if = "Option::is_none")] - previous_response_id: Option, - /// IDs of responses that follow this one. - #[serde(default)] - next_response_ids: Vec, - /// Timestamp when this item was created. - #[serde(default)] - created_at: i64, - /// The tool call ID from the LLM, used to correlate with FunctionCallOutput. - call_id: String, - /// Name of the function to call. - name: String, - /// JSON-encoded arguments for the function. - arguments: String, - /// Status of the function call (typically "in_progress" when pending). - status: String, - /// The model that requested this function call. - model: String, - }, - /// Result of a client-executed function call. - #[serde(rename = "function_call_output")] - FunctionCallOutput { - id: String, - #[serde(default)] - response_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - previous_response_id: Option, - #[serde(default)] - next_response_ids: Vec, - #[serde(default)] - created_at: i64, - /// The call_id from the FunctionCall this is a response to. - call_id: String, - /// Result of the function execution. - output: String, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum ResponseItemStatus { - Completed, - Failed, - InProgress, - Cancelled, -} - -/// Output content from assistant (output-only variants). -/// -/// This type is used for type-safe operations on assistant outputs only. -/// It cannot contain input variants, providing compile-time safety. -/// Used in streaming events and response output items in the API layer. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "type")] -pub enum ResponseOutputContent { - #[serde(rename = "output_text")] - OutputText { - text: String, - annotations: Vec, - }, - #[serde(rename = "tool_calls")] - ToolCalls { - tool_calls: Vec, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseOutputFunction { - pub name: String, - pub arguments: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseOutputToolCall { - pub id: String, - #[serde(rename = "type")] - pub type_: String, - pub function: ResponseOutputFunction, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseReasoningOutput { - #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum ResponseToolChoiceOutput { - Auto(String), - Object { - #[serde(rename = "type")] - type_: String, - function: ResponseToolChoiceFunction, - }, -} - -/// Response deletion result -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseDeleteResult { - pub id: String, - pub object: String, // "response" - pub deleted: bool, -} - -// ============================================ -// Response Streaming Event Types -// ============================================ - -/// Response streaming event wrapper -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ResponseStreamEvent { - #[serde(rename = "type")] - pub event_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub response: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_index: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub content_index: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub item: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub item_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub part: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delta: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, -} - /// Input item list for responses #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ResponseInputItemList { @@ -1391,45 +966,16 @@ pub struct ResponseInputItemList { // ============================================ // Conversation API Models +// Note: CreateConversationRequest, UpdateConversationRequest, ConversationObject, +// and ConversationDeleteResult are re-exported from services::responses::models // ============================================ -/// Request to create a conversation -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateConversationRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -/// Request to update a conversation -#[derive(Debug, Deserialize, ToSchema)] -pub struct UpdateConversationRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - /// Request to create items in a conversation #[derive(Debug, Deserialize, ToSchema)] pub struct CreateConversationItemsRequest { pub items: Vec, } -/// Conversation object (follows OpenAI spec) -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ConversationObject { - pub id: String, - pub object: String, // "conversation" - pub created_at: i64, - pub metadata: serde_json::Value, -} - -/// Deleted conversation result -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct ConversationDeleteResult { - pub id: String, - pub object: String, // "conversation.deleted" - pub deleted: bool, -} - /// Input item for conversations #[derive(Debug, Deserialize, ToSchema)] #[serde(tag = "type")] @@ -1479,14 +1025,7 @@ pub enum ConversationContentPart { }, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct McpDiscoveredTool { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub input_schema: Option, -} +// McpDiscoveredTool is re-exported from services::responses::models /// Conversation item (for responses) #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -1696,86 +1235,8 @@ pub struct ConversationBatchResponse { pub missing_ids: Vec, } -// ============================================ -// Validation implementations -// ============================================ - -impl CreateResponseRequest { - pub fn validate(&self) -> Result<(), String> { - if self.model.trim().is_empty() { - return Err("Model cannot be empty".to_string()); - } - - if let Some(max_tokens) = self.max_output_tokens { - if max_tokens == 0 { - return Err("max_output_tokens must be greater than 0".to_string()); - } - } - - if let Some(max_calls) = self.max_tool_calls { - if max_calls == 0 { - return Err("max_tool_calls must be greater than 0".to_string()); - } - } - - if let Some(temp) = self.temperature { - if !(0.0..=2.0).contains(&temp) { - return Err("temperature must be between 0.0 and 2.0".to_string()); - } - } - - if let Some(top_p) = self.top_p { - if top_p <= 0.0 || top_p > 1.0 { - return Err("top_p must be between 0.0 and 1.0".to_string()); - } - } - - // Validate mutual exclusivity - if self.conversation.is_some() && self.previous_response_id.is_some() { - return Err("Cannot specify both conversation and previous_response_id".to_string()); - } - - Ok(()) - } -} - -impl CreateConversationRequest { - pub fn validate(&self) -> Result<(), String> { - if let Some(metadata) = &self.metadata { - // Prevent extremely large metadata blobs from being stored - let serialized = - serde_json::to_string(metadata).map_err(|_| "Invalid metadata".to_string())?; - // Allow reasonably large metadata but cap to protect the database - if serialized.len() > MAX_METADATA_SIZE_BYTES { - return Err(format!( - "metadata is too large (max {} bytes when serialized)", - MAX_METADATA_SIZE_BYTES - )); - } - } - - Ok(()) - } -} - -impl UpdateConversationRequest { - pub fn validate(&self) -> Result<(), String> { - if let Some(metadata) = &self.metadata { - // Prevent extremely large metadata blobs from being stored - let serialized = - serde_json::to_string(metadata).map_err(|_| "Invalid metadata".to_string())?; - // Allow reasonably large metadata but cap to protect the database - if serialized.len() > MAX_METADATA_SIZE_BYTES { - return Err(format!( - "metadata is too large (max {} bytes when serialized)", - MAX_METADATA_SIZE_BYTES - )); - } - } - - Ok(()) - } -} +// Validation implementations are in services::responses::models +// for CreateResponseRequest, CreateConversationRequest, and UpdateConversationRequest #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateApiKeyRequest { @@ -2951,31 +2412,38 @@ mod tests { let request = result.unwrap(); assert_eq!(request.model, "gpt-4.1"); + use services::responses::models::ResponseInputItem as SvcInputItem; + if let Some(ResponseInput::Items(items)) = request.input { assert_eq!(items.len(), 1); - assert_eq!(items[0].role, "user"); - if let ResponseContent::Parts(parts) = &items[0].content { - assert_eq!(parts.len(), 2); - assert!(matches!(parts[0], ResponseContentPart::InputText { .. })); - assert!(matches!(parts[1], ResponseContentPart::InputImage { .. })); + if let SvcInputItem::Message { role, content, .. } = &items[0] { + assert_eq!(role, "user"); - if let ResponseContentPart::InputText { text } = &parts[0] { - assert_eq!(text, "what is in this image?"); - } + if let ResponseContent::Parts(parts) = content { + assert_eq!(parts.len(), 2); + assert!(matches!(parts[0], ResponseContentPart::InputText { .. })); + assert!(matches!(parts[1], ResponseContentPart::InputImage { .. })); - if let ResponseContentPart::InputImage { image_url, .. } = &parts[1] { - match image_url { - ResponseImageUrl::String(url) => { - assert_eq!(url, "https://example.com/image.jpg"); - } - ResponseImageUrl::Object { url } => { - assert_eq!(url, "https://example.com/image.jpg"); + if let ResponseContentPart::InputText { text } = &parts[0] { + assert_eq!(text, "what is in this image?"); + } + + if let ResponseContentPart::InputImage { image_url, .. } = &parts[1] { + match image_url { + ResponseImageUrl::String(url) => { + assert_eq!(url, "https://example.com/image.jpg"); + } + ResponseImageUrl::Object { url } => { + assert_eq!(url, "https://example.com/image.jpg"); + } } } + } else { + panic!("Expected Parts content"); } } else { - panic!("Expected Parts content"); + panic!("Expected Message variant"); } } else { panic!("Expected Items input"); diff --git a/crates/api/src/routes/conversations.rs b/crates/api/src/routes/conversations.rs index 093ddac3..e8a6539b 100644 --- a/crates/api/src/routes/conversations.rs +++ b/crates/api/src/routes/conversations.rs @@ -952,6 +952,22 @@ pub async fn create_conversation_items( )); } + // Validate metadata size for each message item + for item in &request.items { + if let ConversationInputItem::Message { + metadata: Some(meta), + .. + } = item + { + services::common::validate_metadata_size(meta, "message metadata").map_err(|e| { + ( + StatusCode::BAD_REQUEST, + ResponseJson(ErrorResponse::new(e, "invalid_request_error".to_string())), + ) + })?; + } + } + let parsed_conversation_id = match parse_conversation_id(&conversation_id) { Ok(id) => id, Err(error) => { @@ -1255,14 +1271,7 @@ fn convert_output_item_to_conversation_item( } => ConversationItem::McpListTools { id, server_label, - tools: tools - .into_iter() - .map(|t| crate::models::McpDiscoveredTool { - name: t.name, - description: t.description, - input_schema: t.input_schema, - }) - .collect(), + tools, error, }, ResponseOutputItem::McpCall { diff --git a/crates/api/tests/e2e_conversations.rs b/crates/api/tests/e2e_conversations.rs index eb5e741c..1418883f 100644 --- a/crates/api/tests/e2e_conversations.rs +++ b/crates/api/tests/e2e_conversations.rs @@ -4,7 +4,7 @@ mod common; use common::*; use api::models::{ - ConversationContentPart, ConversationItem, ResponseOutputContent, ResponseOutputItem, + ConversationContentPart, ConversationItem, ResponseContentItem, ResponseOutputItem, }; // Helper functions for conversation and response tests @@ -391,7 +391,7 @@ async fn test_responses_api() { 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 { + if let ResponseContentItem::OutputText { text, .. } = content_part { println!( "Response text length: {} chars, content: '{}'", text.len(), @@ -466,7 +466,7 @@ async fn test_streaming_responses_api() { assert!(streaming_response.output.iter().any(|item| { if let ResponseOutputItem::Message { content, .. } = item { content.iter().any(|part| { - if let ResponseOutputContent::OutputText { text, .. } = part { + if let ResponseContentItem::OutputText { text, .. } = part { !text.is_empty() } else { false @@ -1634,7 +1634,7 @@ async fn test_response_previous_next_relationships() { 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 { + if let api::models::ResponseContentItem::OutputText { text, .. } = part { text.contains("Eiffel Tower was built in 1889") } else { false @@ -3911,7 +3911,7 @@ async fn test_responses_api_with_json_schema() { if let ResponseOutputItem::Message { content, .. } = &response_obj.output[0] { assert!(!content.is_empty()); - if let ResponseOutputContent::OutputText { text, .. } = &content[0] { + if let ResponseContentItem::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"); diff --git a/crates/api/tests/e2e_files.rs b/crates/api/tests/e2e_files.rs index 497d0b80..5585aa79 100644 --- a/crates/api/tests/e2e_files.rs +++ b/crates/api/tests/e2e_files.rs @@ -1011,7 +1011,7 @@ async fn test_file_in_response_api() { 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 { + if let api::models::ResponseContentItem::OutputText { text, .. } = part { final_text.push_str(text); } } diff --git a/crates/api/tests/e2e_function_tools.rs b/crates/api/tests/e2e_function_tools.rs index ddb7e37b..c16fcc26 100644 --- a/crates/api/tests/e2e_function_tools.rs +++ b/crates/api/tests/e2e_function_tools.rs @@ -179,7 +179,7 @@ async fn test_function_tool_single_call() { 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(), + api::models::ResponseContentItem::OutputText { text, .. } => text.clone(), _ => panic!("Expected OutputText content"), } } else { @@ -401,7 +401,7 @@ async fn test_function_tool_parallel_calls() { let text = if let api::models::ResponseOutputItem::Message { content, .. } = final_message.unwrap() { match &content[0] { - api::models::ResponseOutputContent::OutputText { text, .. } => text.clone(), + api::models::ResponseContentItem::OutputText { text, .. } => text.clone(), _ => panic!("Expected OutputText content"), } } else { diff --git a/crates/api/tests/e2e_mcp.rs b/crates/api/tests/e2e_mcp.rs index 1f32e5be..211ba85d 100644 --- a/crates/api/tests/e2e_mcp.rs +++ b/crates/api/tests/e2e_mcp.rs @@ -278,7 +278,7 @@ async fn test_mcp_multi_turn_conversation_with_approval() { 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(), + api::models::ResponseContentItem::OutputText { text, .. } => text.clone(), _ => panic!("Expected OutputText content"), } } else { diff --git a/crates/api/tests/e2e_message_metadata.rs b/crates/api/tests/e2e_message_metadata.rs index c7d7a3a9..f000fd88 100644 --- a/crates/api/tests/e2e_message_metadata.rs +++ b/crates/api/tests/e2e_message_metadata.rs @@ -686,3 +686,48 @@ async fn test_conversation_items_metadata_precedence() { panic!("Expected Message item"); } } + +/// Test that oversized metadata on create_conversation_items is rejected +#[tokio::test] +async fn test_create_conversation_items_metadata_size_limit() { + let (server, _guard) = setup_test_server().await; + let (api_key, _) = create_org_and_api_key(&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(); + + // Build metadata that exceeds the 16KB limit + let large_string = "x".repeat(17 * 1024); + + // POST /v1/conversations/{id}/items with oversized metadata + let response = server + .post(format!("/v1/conversations/{}/items", conversation.id).as_str()) + .add_header("Authorization", format!("Bearer {api_key}")) + .json(&json!({ + "items": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}], + "metadata": { "big": large_string } + } + ] + })) + .await; + + assert_eq!(response.status_code(), 400); + let error: api::models::ErrorResponse = response.json(); + assert!( + error.error.message.contains("metadata is too large"), + "Error should mention metadata size, got: {}", + error.error.message + ); +} diff --git a/crates/services/src/common.rs b/crates/services/src/common.rs index b552451b..0433f1e9 100644 --- a/crates/services/src/common.rs +++ b/crates/services/src/common.rs @@ -7,6 +7,28 @@ pub const API_KEY_LENGTH: usize = 35; /// Maximum serialized size for metadata blobs (e.g. conversation metadata, response metadata) pub const MAX_METADATA_SIZE_BYTES: usize = 16 * 1024; +/// Validates that a JSON-serializable value doesn't exceed the maximum metadata size. +/// +/// # Arguments +/// * `value` - The value to validate (must be serializable to JSON) +/// * `field_name` - Name of the field for error messages (e.g. "metadata", "message metadata") +/// +/// # Returns +/// * `Ok(())` if the serialized size is within limits +/// * `Err(String)` with a descriptive error message if validation fails +pub fn validate_metadata_size( + value: &T, + field_name: &str, +) -> Result<(), String> { + let serialized = serde_json::to_string(value).map_err(|_| format!("Invalid {field_name}"))?; + if serialized.len() > MAX_METADATA_SIZE_BYTES { + return Err(format!( + "{field_name} is too large (max {MAX_METADATA_SIZE_BYTES} bytes when serialized)" + )); + } + Ok(()) +} + /// Encryption header keys used in params.extra for passing encryption information /// These keys are used to pass encryption headers from API routes to completion services. /// Note: These use underscores (x_signing_algo) for params.extra HashMap keys, diff --git a/crates/services/src/responses/models.rs b/crates/services/src/responses/models.rs index 05218160..f0ca1853 100644 --- a/crates/services/src/responses/models.rs +++ b/crates/services/src/responses/models.rs @@ -1135,7 +1135,7 @@ pub struct OutputTokensDetails { impl CreateResponseRequest { pub fn validate(&self) -> Result<(), String> { - use crate::common::MAX_METADATA_SIZE_BYTES; + use crate::common::validate_metadata_size; if self.model.trim().is_empty() { return Err("Model cannot be empty".to_string()); @@ -1166,14 +1166,7 @@ impl CreateResponseRequest { } if let Some(metadata) = &self.metadata { - let serialized = - serde_json::to_string(metadata).map_err(|_| "Invalid metadata".to_string())?; - if serialized.len() > MAX_METADATA_SIZE_BYTES { - return Err(format!( - "metadata is too large (max {} bytes when serialized)", - MAX_METADATA_SIZE_BYTES - )); - } + validate_metadata_size(metadata, "metadata")?; } // Validate input message metadata sizes @@ -1184,14 +1177,7 @@ impl CreateResponseRequest { .. } = item { - let serialized = serde_json::to_string(meta) - .map_err(|_| "Invalid message metadata".to_string())?; - if serialized.len() > MAX_METADATA_SIZE_BYTES { - return Err(format!( - "message metadata is too large (max {} bytes when serialized)", - MAX_METADATA_SIZE_BYTES - )); - } + validate_metadata_size(meta, "message metadata")?; } } } @@ -1202,7 +1188,18 @@ impl CreateResponseRequest { impl CreateConversationRequest { pub fn validate(&self) -> Result<(), String> { - // Basic validation - can be extended if needed + if let Some(metadata) = &self.metadata { + crate::common::validate_metadata_size(metadata, "metadata")?; + } + Ok(()) + } +} + +impl UpdateConversationRequest { + pub fn validate(&self) -> Result<(), String> { + if let Some(metadata) = &self.metadata { + crate::common::validate_metadata_size(metadata, "metadata")?; + } Ok(()) } }