Add OpenAI/LiteLLM provider support with settings UI
- Add openai/ provider module with translator, client, convert, request/response translators - Add shared provider/ types (ConversationMessage, MessageRole, ProviderConfig enum) - Wire OpenAI-compatible provider dispatch alongside Bedrock in response_stream.rs - Add ai.openai.* settings (enabled, base_url, api_key, model, models) - Add OpenAI/LiteLLM settings page with model fetch, picker, and config UI - Extend model menu items and llms.rs to surface LiteLLM models - Update WARP.md with OpenAI provider architecture docs
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::ai::openai::request_translator::sanitize_messages_for_openai;
|
||||
use crate::ai::provider::types::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||
|
||||
#[test]
|
||||
fn test_removes_orphaned_tool_results() {
|
||||
let mut messages = vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Hello".to_string()),
|
||||
},
|
||||
// This tool result references a tool_use that doesn't exist
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: "nonexistent_id".to_string(),
|
||||
content: "some result".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
sanitize_messages_for_openai(&mut messages);
|
||||
|
||||
assert_eq!(messages.len(), 1);
|
||||
matches!(&messages[0].content, MessageContent::Text(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keeps_valid_tool_results() {
|
||||
let mut messages = vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::ToolUse {
|
||||
tool_use_id: "valid_id".to_string(),
|
||||
name: "run_shell_command".to_string(),
|
||||
input: json!({"command": "ls"}),
|
||||
},
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: "valid_id".to_string(),
|
||||
content: "file1.txt".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
sanitize_messages_for_openai(&mut messages);
|
||||
|
||||
assert_eq!(messages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synthesizes_missing_tool_results() {
|
||||
let mut messages = vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::ToolUse {
|
||||
tool_use_id: "unanswered_id".to_string(),
|
||||
name: "read_files".to_string(),
|
||||
input: json!({"files": ["test.rs"]}),
|
||||
},
|
||||
},
|
||||
// No corresponding tool result!
|
||||
];
|
||||
|
||||
sanitize_messages_for_openai(&mut messages);
|
||||
|
||||
// Should have synthesized a tool result
|
||||
assert_eq!(messages.len(), 2);
|
||||
match &messages[1].content {
|
||||
MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(tool_use_id, "unanswered_id");
|
||||
assert!(*is_error);
|
||||
}
|
||||
_ => panic!("Expected ToolResult"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_does_not_require_user_assistant_alternation() {
|
||||
let mut messages = vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("First message".to_string()),
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Second message".to_string()),
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text("Response".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
sanitize_messages_for_openai(&mut messages);
|
||||
|
||||
// Both user messages should remain — OpenAI allows consecutive same-role
|
||||
assert_eq!(messages.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_does_not_require_starting_with_user() {
|
||||
let mut messages = vec![ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text("I start the conversation".to_string()),
|
||||
}];
|
||||
|
||||
sanitize_messages_for_openai(&mut messages);
|
||||
|
||||
// Should NOT prepend a user message (unlike Bedrock)
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].role, MessageRole::Assistant);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multipart_tool_uses_all_get_results() {
|
||||
let mut messages = vec![ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::MultiPart(vec![
|
||||
ContentPart::ToolUse {
|
||||
tool_use_id: "id_1".to_string(),
|
||||
name: "grep".to_string(),
|
||||
input: json!({"queries": ["test"]}),
|
||||
},
|
||||
ContentPart::ToolUse {
|
||||
tool_use_id: "id_2".to_string(),
|
||||
name: "file_glob".to_string(),
|
||||
input: json!({"patterns": ["*.rs"]}),
|
||||
},
|
||||
]),
|
||||
}];
|
||||
|
||||
sanitize_messages_for_openai(&mut messages);
|
||||
|
||||
// Should synthesize results for both unanswered tool calls
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[1].role, MessageRole::User);
|
||||
assert_eq!(messages[2].role, MessageRole::User);
|
||||
}
|
||||
Reference in New Issue
Block a user