Files
galaxy/app/src/ai/openai/convert.rs
T

309 lines
10 KiB
Rust

use base64::engine::general_purpose;
use base64::Engine as _;
use serde_json::{json, Value as JsonValue};
use crate::ai::provider::types::{
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
};
/// Sanitizes a tool_use_id to match Bedrock's required pattern `^[a-zA-Z0-9_-]+$`.
/// LiteLLM may proxy to Bedrock which rejects IDs with characters outside this set.
/// Replaces any invalid character with an underscore.
fn sanitize_tool_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
pub fn build_openai_request(
messages: Vec<ConversationMessage>,
system_prompt: Option<String>,
tools: Vec<ToolDefinition>,
max_tokens: i32,
temperature: Option<f32>,
model: &str,
) -> JsonValue {
let mut openai_messages: Vec<JsonValue> = Vec::new();
if let Some(prompt) = system_prompt {
if !prompt.is_empty() {
openai_messages.push(json!({
"role": "system",
"content": prompt,
}));
}
}
for msg in messages {
match convert_message(msg) {
ConvertedMessages::Single(m) => openai_messages.push(m),
ConvertedMessages::Multiple(ms) => openai_messages.extend(ms),
}
}
let mut request = json!({
"model": model,
"messages": openai_messages,
"max_tokens": max_tokens,
"stream": true,
"stream_options": { "include_usage": true },
});
if let Some(temp) = temperature {
request["temperature"] = json!(temp);
}
if !tools.is_empty() {
let tool_defs: Vec<JsonValue> = tools.into_iter().map(convert_tool_definition).collect();
request["tools"] = json!(tool_defs);
}
request
}
enum ConvertedMessages {
Single(JsonValue),
Multiple(Vec<JsonValue>),
}
enum UserContentPart {
Text(String),
Image { data: Vec<u8>, mime_type: String },
}
fn convert_message(msg: ConversationMessage) -> ConvertedMessages {
match msg.role {
MessageRole::User => convert_user_message(msg.content),
MessageRole::Assistant => convert_assistant_message(msg.content),
}
}
fn convert_user_message(content: MessageContent) -> ConvertedMessages {
match content {
MessageContent::Text(text) => ConvertedMessages::Single(json!({
"role": "user",
"content": text,
})),
MessageContent::ToolResult {
tool_use_id,
content,
is_error,
} => {
let mut msg = json!({
"role": "tool",
"tool_call_id": sanitize_tool_id(&tool_use_id),
"content": content,
});
if is_error {
msg["content"] = json!(format!("[ERROR] {content}"));
}
ConvertedMessages::Single(msg)
}
MessageContent::ToolUse { .. } => {
// User messages shouldn't contain tool_use, but handle gracefully
ConvertedMessages::Single(json!({
"role": "user",
"content": "[unexpected tool_use in user message]",
}))
}
MessageContent::MultiPart(parts) => {
let mut messages = Vec::new();
let mut user_content_parts = Vec::new();
for part in parts {
match part {
ContentPart::Text(text) => {
user_content_parts.push(UserContentPart::Text(text));
}
ContentPart::Reasoning { text, .. } => {
user_content_parts.push(UserContentPart::Text(text));
}
ContentPart::Image { data, mime_type } => {
user_content_parts.push(UserContentPart::Image { data, mime_type });
}
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
flush_user_content(&mut messages, &mut user_content_parts);
let result_content = if is_error {
format!("[ERROR] {content}")
} else {
content
};
messages.push(json!({
"role": "tool",
"tool_call_id": sanitize_tool_id(&tool_use_id),
"content": result_content,
}));
}
ContentPart::ToolUse { .. } => {
user_content_parts.push(UserContentPart::Text(
"[unexpected tool_use in user message]".to_string(),
));
}
}
}
flush_user_content(&mut messages, &mut user_content_parts);
if messages.len() == 1 {
ConvertedMessages::Single(messages.into_iter().next().unwrap())
} else {
ConvertedMessages::Multiple(messages)
}
}
}
}
fn convert_assistant_message(content: MessageContent) -> ConvertedMessages {
match content {
MessageContent::Text(text) => ConvertedMessages::Single(json!({
"role": "assistant",
"content": text,
})),
MessageContent::ToolUse {
tool_use_id,
name,
input,
} => ConvertedMessages::Single(json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": sanitize_tool_id(&tool_use_id),
"type": "function",
"function": {
"name": name,
"arguments": input.to_string(),
}
}]
})),
MessageContent::ToolResult { .. } => {
// Assistant messages shouldn't contain tool_result
ConvertedMessages::Single(json!({
"role": "assistant",
"content": "[unexpected tool_result in assistant message]",
}))
}
MessageContent::MultiPart(parts) => {
let mut text_content = String::new();
let mut tool_calls: Vec<JsonValue> = Vec::new();
for part in parts {
match part {
ContentPart::Text(text) => {
if !text_content.is_empty() {
text_content.push('\n');
}
text_content.push_str(&text);
}
ContentPart::Reasoning { text, .. } => {
if !text_content.is_empty() {
text_content.push('\n');
}
text_content.push_str(&text);
}
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => {
tool_calls.push(json!({
"id": sanitize_tool_id(&tool_use_id),
"type": "function",
"function": {
"name": name,
"arguments": input.to_string(),
}
}));
}
ContentPart::ToolResult { .. } => {}
ContentPart::Image { .. } => {
if !text_content.is_empty() {
text_content.push('\n');
}
text_content.push_str("[unexpected image in assistant message]");
}
}
}
let mut msg = json!({ "role": "assistant" });
if !text_content.is_empty() {
msg["content"] = json!(text_content);
} else {
msg["content"] = JsonValue::Null;
}
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
ConvertedMessages::Single(msg)
}
}
}
fn flush_user_content(messages: &mut Vec<JsonValue>, content_parts: &mut Vec<UserContentPart>) {
if content_parts.is_empty() {
return;
}
let has_image = content_parts
.iter()
.any(|part| matches!(part, UserContentPart::Image { .. }));
let content = if has_image {
JsonValue::Array(
std::mem::take(content_parts)
.into_iter()
.map(|part| match part {
UserContentPart::Text(text) => json!({
"type": "text",
"text": text,
}),
UserContentPart::Image { data, mime_type } => {
let data = general_purpose::STANDARD.encode(data);
json!({
"type": "image_url",
"image_url": {
"url": format!("data:{mime_type};base64,{data}"),
},
})
}
})
.collect(),
)
} else {
JsonValue::String(
std::mem::take(content_parts)
.into_iter()
.map(|part| match part {
UserContentPart::Text(text) => text,
UserContentPart::Image { .. } => unreachable!(),
})
.collect::<Vec<_>>()
.join("\n"),
)
};
messages.push(json!({
"role": "user",
"content": content,
}));
}
fn convert_tool_definition(tool: ToolDefinition) -> JsonValue {
json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
}
})
}