diff --git a/desktop/macos/Backend-Rust/src/models/chat_completions.rs b/desktop/macos/Backend-Rust/src/models/chat_completions.rs index d1a2a95fbdf..2fd0e1f6d6c 100644 --- a/desktop/macos/Backend-Rust/src/models/chat_completions.rs +++ b/desktop/macos/Backend-Rust/src/models/chat_completions.rs @@ -176,7 +176,6 @@ pub struct AnthropicRequest { // String or array-of-content-blocks. We emit the block form with a // cache_control breakpoint to cache the static tools+system prefix. #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, pub stream: bool, @@ -192,6 +191,34 @@ pub struct AnthropicMessage { pub content: serde_json::Value, } +/// Anthropic content block type (system prompt blocks are always "text"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AnthropicContentBlockType { + Text, +} + +/// Anthropic cache control type (currently only "ephemeral" is supported). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AnthropicCacheControlType { + Ephemeral, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AnthropicSystemContentBlock { + #[serde(rename = "type")] + pub block_type: AnthropicContentBlockType, + pub text: String, + pub cache_control: AnthropicCacheControl, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AnthropicCacheControl { + #[serde(rename = "type")] + pub cache_type: AnthropicCacheControlType, +} + #[derive(Debug, Clone, Serialize)] pub struct AnthropicTool { pub name: String, diff --git a/desktop/macos/Backend-Rust/src/routes/chat_completions.rs b/desktop/macos/Backend-Rust/src/routes/chat_completions.rs index 3205aadaeb3..b8f8382b6d6 100644 --- a/desktop/macos/Backend-Rust/src/routes/chat_completions.rs +++ b/desktop/macos/Backend-Rust/src/routes/chat_completions.rs @@ -237,7 +237,23 @@ fn translate_request( model: upstream_model.to_string(), max_tokens, messages: anthropic_messages, - system, +<<<<<<< HEAD +======= + system: system_prompt.and_then(|text| { + let text = text.trim().to_string(); + if text.is_empty() { + None + } else { + Some(vec![AnthropicSystemContentBlock { + block_type: AnthropicContentBlockType::Text, + text, + cache_control: AnthropicCacheControl { + cache_type: AnthropicCacheControlType::Ephemeral, + }, + }]) + } + }), +>>>>>>> 608ebd12c3 (refactor: replace raw String types with typed enums for Anthropic block/cache types) temperature: req.temperature, stream: req.stream, tools: if is_tool_choice_none { None } else { anthropic_tools }, @@ -1269,10 +1285,19 @@ mod tests { let result = translate_request(&req, "claude-sonnet-4-6").unwrap(); assert_eq!(result.model, "claude-sonnet-4-6"); - // system is now an ephemeral-cached content-block array, not a bare string. - let system = result.system.as_ref().expect("system block should be present"); - assert_eq!(system[0]["text"], "You are helpful."); - assert_eq!(system[0]["cache_control"]["type"], "ephemeral"); +<<<<<<< HEAD +======= + assert_eq!( + result.system, + Some(vec![AnthropicSystemContentBlock { + block_type: AnthropicContentBlockType::Text, + text: "You are helpful.".to_string(), + cache_control: AnthropicCacheControl { + cache_type: AnthropicCacheControlType::Ephemeral, + }, + }]) + ); +>>>>>>> 608ebd12c3 (refactor: replace raw String types with typed enums for Anthropic block/cache types) assert_eq!(result.messages.len(), 1); // only user message, system extracted assert_eq!(result.messages[0].role, "user"); assert_eq!(result.max_tokens, 1024); @@ -1431,13 +1456,113 @@ mod tests { }; let result = translate_request(&req, "claude-sonnet-4-6").unwrap(); - let system = result.system.as_ref().expect("system block should be present"); - assert_eq!(system[0]["text"], "You are terse."); - assert_eq!(system[0]["cache_control"]["type"], "ephemeral"); - assert_eq!(result.messages.len(), 1, "developer msg must be extracted, not forwarded"); assert_eq!(result.messages[0].role, "user"); } + #[test] + fn test_translate_request_system_prompt_uses_cache_control_blocks() { + let req = ChatCompletionRequest { + model: "omi-sonnet".to_string(), + messages: vec![ + ChatMessage { + role: "system".to_string(), + content: Some(json!("You are helpful.")), + name: None, + tool_calls: None, + tool_call_id: None, + }, + ChatMessage { + role: "user".to_string(), + content: Some(json!("Hello")), + name: None, + tool_calls: None, + tool_call_id: None, + }, + ], + stream: false, + temperature: None, + max_tokens: None, + max_completion_tokens: None, + tools: None, + tool_choice: None, + }; + + let result = translate_request(&req, "claude-sonnet-4-6").unwrap(); + let json = serde_json::to_value(&result).unwrap(); + + assert_eq!( + json["system"], + json!([{ + "type": "text", + "text": "You are helpful.", + "cache_control": {"type": "ephemeral"} + }]) + ); + } + + #[test] + fn test_translate_request_without_system_prompt_omits_system() { + let req = ChatCompletionRequest { + model: "omi-sonnet".to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: Some(json!("Hello")), + name: None, + tool_calls: None, + tool_call_id: None, + }], + stream: false, + temperature: None, + max_tokens: None, + max_completion_tokens: None, + tools: None, + tool_choice: None, + }; + + let result = translate_request(&req, "claude-sonnet-4-6").unwrap(); + let json = serde_json::to_value(&result).unwrap(); + + assert!(result.system.is_none()); + assert!(json.get("system").is_none()); + } + + #[test] + fn test_translate_request_empty_system_prompt_omits_system() { + // Empty or whitespace-only system prompts must NOT be sent as cached blocks + // (Anthropic rejects empty cached text blocks with 400). + for content in [Some(json!("")), Some(json!(" ")), None] { + let req = ChatCompletionRequest { + model: "omi-sonnet".to_string(), + messages: vec![ChatMessage { + role: "system".to_string(), + content: content.clone(), + name: None, + tool_calls: None, + tool_call_id: None, + }, ChatMessage { + role: "user".to_string(), + content: Some(json!("Hello")), + name: None, + tool_calls: None, + tool_call_id: None, + }], + stream: false, + temperature: None, + max_tokens: None, + max_completion_tokens: None, + tools: None, + tool_choice: None, + }; + + let result = translate_request(&req, "claude-sonnet-4-6").unwrap(); + assert!( + result.system.is_none(), + "empty/whitespace system prompt must omit system field, got: {:?}", + result.system + ); + } + } + #[test] fn test_translate_request_max_completion_tokens_preferred() { // OpenAI renamed `max_tokens` → `max_completion_tokens` for reasoning