mirror of
https://github.com/katanemo/plano.git
synced 2026-07-02 15:51:02 +02:00
1535 lines
55 KiB
Rust
1535 lines
55 KiB
Rust
use crate::apis::amazon_bedrock::{
|
|
ConverseOutput, ConverseResponse, ConverseStreamEvent, StopReason,
|
|
};
|
|
use crate::apis::anthropic::{
|
|
MessagesContentBlock, MessagesContentDelta, MessagesResponse, MessagesStopReason,
|
|
MessagesStreamEvent, MessagesUsage,
|
|
};
|
|
use crate::apis::openai::{
|
|
ChatCompletionsResponse, ChatCompletionsStreamResponse, Choice, FinishReason,
|
|
FunctionCallDelta, MessageContent, MessageDelta, ResponseMessage, Role, StreamChoice,
|
|
ToolCallDelta, Usage,
|
|
};
|
|
use crate::apis::openai_responses::ResponsesAPIResponse;
|
|
use crate::clients::TransformError;
|
|
use crate::transforms::lib::*;
|
|
|
|
// ============================================================================
|
|
// MAIN RESPONSE TRANSFORMATIONS
|
|
// ============================================================================
|
|
|
|
// Usage Conversions
|
|
impl Into<Usage> for MessagesUsage {
|
|
fn into(self) -> Usage {
|
|
Usage {
|
|
prompt_tokens: self.input_tokens,
|
|
completion_tokens: self.output_tokens,
|
|
total_tokens: self.input_tokens + self.output_tokens,
|
|
prompt_tokens_details: None,
|
|
completion_tokens_details: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<ChatCompletionsResponse> for ResponsesAPIResponse {
|
|
type Error = TransformError;
|
|
|
|
fn try_from(resp: ChatCompletionsResponse) -> Result<Self, Self::Error> {
|
|
use crate::apis::openai_responses::{
|
|
IncompleteDetails, IncompleteReason, OutputContent, OutputItem, OutputItemStatus,
|
|
ResponseStatus, ResponseUsage, ResponsesAPIResponse,
|
|
};
|
|
|
|
// Convert the first choice's message to output items
|
|
let output = if let Some(choice) = resp.choices.first() {
|
|
let mut items = Vec::new();
|
|
|
|
// Create a message output item from the response message
|
|
let mut content = Vec::new();
|
|
|
|
// Add text content if present
|
|
if let Some(text) = &choice.message.content {
|
|
content.push(OutputContent::OutputText {
|
|
text: text.clone(),
|
|
annotations: vec![],
|
|
logprobs: None,
|
|
});
|
|
}
|
|
|
|
// Add audio content if present (audio is a Value, need to handle it carefully)
|
|
if let Some(audio) = &choice.message.audio {
|
|
// Audio is serde_json::Value, try to extract data and transcript
|
|
if let Some(audio_obj) = audio.as_object() {
|
|
let data = audio_obj
|
|
.get("data")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
let transcript = audio_obj
|
|
.get("transcript")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
|
|
content.push(OutputContent::OutputAudio { data, transcript });
|
|
}
|
|
}
|
|
|
|
// Add refusal content if present
|
|
if let Some(refusal) = &choice.message.refusal {
|
|
content.push(OutputContent::Refusal {
|
|
refusal: refusal.clone(),
|
|
});
|
|
}
|
|
|
|
// Only add the message item if there's actual content (text, audio, or refusal)
|
|
// Don't add empty message items when there are only tool calls
|
|
if !content.is_empty() {
|
|
items.push(OutputItem::Message {
|
|
id: format!("msg_{}", resp.id),
|
|
status: OutputItemStatus::Completed,
|
|
role: match choice.message.role {
|
|
Role::User => "user".to_string(),
|
|
Role::Assistant => "assistant".to_string(),
|
|
Role::System => "system".to_string(),
|
|
Role::Tool => "tool".to_string(),
|
|
},
|
|
content,
|
|
});
|
|
}
|
|
|
|
// Add tool calls as function call items if present
|
|
if let Some(tool_calls) = &choice.message.tool_calls {
|
|
for tool_call in tool_calls {
|
|
items.push(OutputItem::FunctionCall {
|
|
id: format!("func_{}", tool_call.id),
|
|
status: OutputItemStatus::Completed,
|
|
call_id: tool_call.id.clone(),
|
|
name: Some(tool_call.function.name.clone()),
|
|
arguments: Some(tool_call.function.arguments.clone()),
|
|
});
|
|
}
|
|
}
|
|
|
|
items
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
// Convert finish_reason to status
|
|
let status = if let Some(choice) = resp.choices.first() {
|
|
match choice.finish_reason {
|
|
Some(FinishReason::Stop) => ResponseStatus::Completed,
|
|
Some(FinishReason::ToolCalls) => ResponseStatus::Completed,
|
|
Some(FinishReason::Length) => ResponseStatus::Incomplete,
|
|
Some(FinishReason::ContentFilter) => ResponseStatus::Failed,
|
|
_ => ResponseStatus::Completed,
|
|
}
|
|
} else {
|
|
ResponseStatus::Completed
|
|
};
|
|
|
|
// Convert usage
|
|
let usage = ResponseUsage {
|
|
input_tokens: resp.usage.prompt_tokens as i32,
|
|
output_tokens: resp.usage.completion_tokens as i32,
|
|
total_tokens: resp.usage.total_tokens as i32,
|
|
input_tokens_details: resp.usage.prompt_tokens_details.map(|details| {
|
|
crate::apis::openai_responses::TokenDetails {
|
|
cached_tokens: details.cached_tokens.unwrap_or(0) as i32,
|
|
}
|
|
}),
|
|
output_tokens_details: resp.usage.completion_tokens_details.map(|details| {
|
|
crate::apis::openai_responses::OutputTokenDetails {
|
|
reasoning_tokens: details.reasoning_tokens.unwrap_or(0) as i32,
|
|
}
|
|
}),
|
|
};
|
|
|
|
// Set incomplete_details if status is incomplete
|
|
let incomplete_details = if matches!(status, ResponseStatus::Incomplete) {
|
|
Some(IncompleteDetails {
|
|
reason: IncompleteReason::MaxOutputTokens,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(ResponsesAPIResponse {
|
|
id: resp.id,
|
|
object: "response".to_string(),
|
|
created_at: resp.created as i64,
|
|
status,
|
|
background: Some(false),
|
|
error: None,
|
|
incomplete_details,
|
|
instructions: None,
|
|
max_output_tokens: None,
|
|
max_tool_calls: None,
|
|
model: resp.model,
|
|
output,
|
|
usage: Some(usage),
|
|
parallel_tool_calls: true,
|
|
conversation: None,
|
|
previous_response_id: None,
|
|
tools: vec![],
|
|
tool_choice: "auto".to_string(),
|
|
temperature: 1.0,
|
|
top_p: 1.0,
|
|
metadata: resp.metadata.unwrap_or_default(),
|
|
truncation: None,
|
|
reasoning: None,
|
|
store: None,
|
|
text: None,
|
|
audio: None,
|
|
modalities: None,
|
|
service_tier: resp.service_tier,
|
|
top_logprobs: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
impl TryFrom<MessagesResponse> for ChatCompletionsResponse {
|
|
type Error = TransformError;
|
|
|
|
fn try_from(resp: MessagesResponse) -> Result<Self, Self::Error> {
|
|
let content = convert_anthropic_content_to_openai(&resp.content)?;
|
|
let finish_reason: FinishReason = resp.stop_reason.into();
|
|
let tool_calls = resp.content.extract_tool_calls()?;
|
|
|
|
// Convert MessageContent to String for response
|
|
let content_string = match content {
|
|
MessageContent::Text(text) => Some(text),
|
|
MessageContent::Parts(parts) => {
|
|
let text = parts.extract_text();
|
|
if text.is_empty() {
|
|
None
|
|
} else {
|
|
Some(text)
|
|
}
|
|
}
|
|
};
|
|
|
|
let message = ResponseMessage {
|
|
role: Role::Assistant,
|
|
content: content_string,
|
|
refusal: None,
|
|
annotations: None,
|
|
audio: None,
|
|
function_call: None,
|
|
tool_calls,
|
|
};
|
|
|
|
let choice = Choice {
|
|
index: 0,
|
|
message,
|
|
finish_reason: Some(finish_reason),
|
|
logprobs: None,
|
|
};
|
|
|
|
let usage = Usage {
|
|
prompt_tokens: resp.usage.input_tokens,
|
|
completion_tokens: resp.usage.output_tokens,
|
|
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
|
|
prompt_tokens_details: None,
|
|
completion_tokens_details: None,
|
|
};
|
|
|
|
Ok(ChatCompletionsResponse {
|
|
id: resp.id,
|
|
object: Some("chat.completion".to_string()),
|
|
created: current_timestamp(),
|
|
model: resp.model,
|
|
choices: vec![choice],
|
|
usage,
|
|
..Default::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<ConverseResponse> for ChatCompletionsResponse {
|
|
type Error = TransformError;
|
|
|
|
fn try_from(resp: ConverseResponse) -> Result<Self, Self::Error> {
|
|
// Extract the message from the ConverseOutput
|
|
let message = match resp.output {
|
|
ConverseOutput::Message { message } => message,
|
|
};
|
|
|
|
// Convert Bedrock ConversationRole to OpenAI Role
|
|
let role = match message.role {
|
|
crate::apis::amazon_bedrock::ConversationRole::User => Role::User,
|
|
crate::apis::amazon_bedrock::ConversationRole::Assistant => Role::Assistant,
|
|
};
|
|
|
|
// Convert Bedrock message content to OpenAI format
|
|
let (content, tool_calls) = convert_bedrock_message_to_openai(&message)?;
|
|
|
|
// Convert Bedrock stop reason to OpenAI finish reason
|
|
let finish_reason = match resp.stop_reason {
|
|
StopReason::EndTurn => FinishReason::Stop,
|
|
StopReason::ToolUse => FinishReason::ToolCalls,
|
|
StopReason::MaxTokens => FinishReason::Length,
|
|
StopReason::StopSequence => FinishReason::Stop,
|
|
StopReason::GuardrailIntervened => FinishReason::ContentFilter,
|
|
StopReason::ContentFiltered => FinishReason::ContentFilter,
|
|
};
|
|
|
|
// Create response message
|
|
let response_message = ResponseMessage {
|
|
role,
|
|
content,
|
|
refusal: None,
|
|
annotations: None,
|
|
audio: None,
|
|
function_call: None,
|
|
tool_calls,
|
|
};
|
|
|
|
// Create choice
|
|
let choice = Choice {
|
|
index: 0,
|
|
message: response_message,
|
|
finish_reason: Some(finish_reason),
|
|
logprobs: None,
|
|
};
|
|
|
|
// Convert token usage
|
|
let usage = Usage {
|
|
prompt_tokens: resp.usage.input_tokens,
|
|
completion_tokens: resp.usage.output_tokens,
|
|
total_tokens: resp.usage.total_tokens,
|
|
prompt_tokens_details: None,
|
|
completion_tokens_details: None,
|
|
};
|
|
|
|
// Generate a response ID (using timestamp since Bedrock doesn't provide one)
|
|
let id = format!(
|
|
"bedrock-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos()
|
|
);
|
|
|
|
// Extract model ID from trace information if available, otherwise use fallback
|
|
let model = resp
|
|
.trace
|
|
.as_ref()
|
|
.and_then(|trace| trace.prompt_router.as_ref())
|
|
.map(|router| router.invoked_model_id.clone())
|
|
.unwrap_or_else(|| "bedrock-model".to_string());
|
|
|
|
Ok(ChatCompletionsResponse {
|
|
id,
|
|
object: Some("chat.completion".to_string()),
|
|
created: current_timestamp(),
|
|
model,
|
|
choices: vec![choice],
|
|
usage,
|
|
..Default::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// STREAMING TRANSFORMATIONS
|
|
// ============================================================================
|
|
|
|
impl TryFrom<MessagesStreamEvent> for ChatCompletionsStreamResponse {
|
|
type Error = TransformError;
|
|
|
|
fn try_from(event: MessagesStreamEvent) -> Result<Self, Self::Error> {
|
|
match event {
|
|
MessagesStreamEvent::MessageStart { message } => Ok(create_openai_chunk(
|
|
&message.id,
|
|
&message.model,
|
|
MessageDelta {
|
|
role: Some(Role::Assistant),
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
None,
|
|
None,
|
|
)),
|
|
|
|
MessagesStreamEvent::ContentBlockStart { content_block, .. } => {
|
|
convert_content_block_start(content_block)
|
|
}
|
|
|
|
MessagesStreamEvent::ContentBlockDelta { delta, .. } => convert_content_delta(delta),
|
|
|
|
MessagesStreamEvent::ContentBlockStop { .. } => Ok(create_empty_openai_chunk()),
|
|
|
|
MessagesStreamEvent::MessageDelta { delta, usage } => {
|
|
let finish_reason: Option<FinishReason> = Some(delta.stop_reason.into());
|
|
let openai_usage: Option<Usage> = Some(usage.into());
|
|
|
|
Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
finish_reason,
|
|
openai_usage,
|
|
))
|
|
}
|
|
|
|
MessagesStreamEvent::MessageStop => Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
Some(FinishReason::Stop),
|
|
None,
|
|
)),
|
|
|
|
MessagesStreamEvent::Ping => Ok(ChatCompletionsStreamResponse {
|
|
id: "stream".to_string(),
|
|
object: Some("chat.completion.chunk".to_string()),
|
|
created: current_timestamp(),
|
|
model: "unknown".to_string(),
|
|
choices: vec![],
|
|
usage: None,
|
|
system_fingerprint: None,
|
|
service_tier: None,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<ConverseStreamEvent> for ChatCompletionsStreamResponse {
|
|
type Error = TransformError;
|
|
|
|
fn try_from(event: ConverseStreamEvent) -> Result<Self, Self::Error> {
|
|
match event {
|
|
ConverseStreamEvent::MessageStart(start_event) => {
|
|
let role = match start_event.role {
|
|
crate::apis::amazon_bedrock::ConversationRole::User => Role::User,
|
|
crate::apis::amazon_bedrock::ConversationRole::Assistant => Role::Assistant,
|
|
};
|
|
|
|
Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: Some(role),
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
None,
|
|
None,
|
|
))
|
|
}
|
|
|
|
ConverseStreamEvent::ContentBlockStart(start_event) => {
|
|
use crate::apis::amazon_bedrock::ContentBlockStart;
|
|
|
|
match start_event.start {
|
|
ContentBlockStart::ToolUse { tool_use } => Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: Some(vec![ToolCallDelta {
|
|
index: start_event.content_block_index as u32,
|
|
id: Some(tool_use.tool_use_id),
|
|
call_type: Some("function".to_string()),
|
|
function: Some(FunctionCallDelta {
|
|
name: Some(tool_use.name),
|
|
arguments: Some("".to_string()),
|
|
}),
|
|
}]),
|
|
},
|
|
None,
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
|
|
ConverseStreamEvent::ContentBlockDelta(delta_event) => {
|
|
use crate::apis::amazon_bedrock::ContentBlockDelta;
|
|
|
|
match delta_event.delta {
|
|
ContentBlockDelta::Text { text } => Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: Some(text),
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
None,
|
|
None,
|
|
)),
|
|
ContentBlockDelta::ToolUse { tool_use } => Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: Some(vec![ToolCallDelta {
|
|
index: delta_event.content_block_index as u32,
|
|
id: None,
|
|
call_type: None,
|
|
function: Some(FunctionCallDelta {
|
|
name: None,
|
|
arguments: Some(tool_use.input),
|
|
}),
|
|
}]),
|
|
},
|
|
None,
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
|
|
ConverseStreamEvent::ContentBlockStop(_) => Ok(create_empty_openai_chunk()),
|
|
|
|
ConverseStreamEvent::MessageStop(stop_event) => {
|
|
let finish_reason = match stop_event.stop_reason {
|
|
StopReason::EndTurn => FinishReason::Stop,
|
|
StopReason::ToolUse => FinishReason::ToolCalls,
|
|
StopReason::MaxTokens => FinishReason::Length,
|
|
StopReason::StopSequence => FinishReason::Stop,
|
|
StopReason::GuardrailIntervened => FinishReason::ContentFilter,
|
|
StopReason::ContentFiltered => FinishReason::ContentFilter,
|
|
};
|
|
|
|
Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
Some(finish_reason),
|
|
None,
|
|
))
|
|
}
|
|
|
|
ConverseStreamEvent::Metadata(metadata_event) => {
|
|
let usage = Usage {
|
|
prompt_tokens: metadata_event.usage.input_tokens,
|
|
completion_tokens: metadata_event.usage.output_tokens,
|
|
total_tokens: metadata_event.usage.total_tokens,
|
|
prompt_tokens_details: None,
|
|
completion_tokens_details: None,
|
|
};
|
|
|
|
Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
None,
|
|
Some(usage),
|
|
))
|
|
}
|
|
|
|
// Error events - convert to empty chunks (errors should be handled elsewhere)
|
|
ConverseStreamEvent::InternalServerException(_)
|
|
| ConverseStreamEvent::ModelStreamErrorException(_)
|
|
| ConverseStreamEvent::ServiceUnavailableException(_)
|
|
| ConverseStreamEvent::ThrottlingException(_)
|
|
| ConverseStreamEvent::ValidationException(_) => Ok(create_empty_openai_chunk()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert content block start to OpenAI chunk
|
|
fn convert_content_block_start(
|
|
content_block: MessagesContentBlock,
|
|
) -> Result<ChatCompletionsStreamResponse, TransformError> {
|
|
match content_block {
|
|
MessagesContentBlock::Text { .. } => {
|
|
// No immediate output for text block start
|
|
Ok(create_empty_openai_chunk())
|
|
}
|
|
MessagesContentBlock::ToolUse { id, name, .. }
|
|
| MessagesContentBlock::ServerToolUse { id, name, .. }
|
|
| MessagesContentBlock::McpToolUse { id, name, .. } => {
|
|
// Tool use start → OpenAI chunk with tool_calls
|
|
Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: Some(vec![ToolCallDelta {
|
|
index: 0,
|
|
id: Some(id),
|
|
call_type: Some("function".to_string()),
|
|
function: Some(FunctionCallDelta {
|
|
name: Some(name),
|
|
arguments: Some("".to_string()),
|
|
}),
|
|
}]),
|
|
},
|
|
None,
|
|
None,
|
|
))
|
|
}
|
|
_ => Err(TransformError::UnsupportedContent(
|
|
"Unsupported content block type in stream start".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Convert content delta to OpenAI chunk
|
|
fn convert_content_delta(
|
|
delta: MessagesContentDelta,
|
|
) -> Result<ChatCompletionsStreamResponse, TransformError> {
|
|
match delta {
|
|
MessagesContentDelta::TextDelta { text } => Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: Some(text),
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
None,
|
|
None,
|
|
)),
|
|
MessagesContentDelta::ThinkingDelta { thinking } => Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: Some(format!("thinking: {}", thinking)),
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
None,
|
|
None,
|
|
)),
|
|
MessagesContentDelta::InputJsonDelta { partial_json } => Ok(create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: Some(vec![ToolCallDelta {
|
|
index: 0,
|
|
id: None,
|
|
call_type: None,
|
|
function: Some(FunctionCallDelta {
|
|
name: None,
|
|
arguments: Some(partial_json),
|
|
}),
|
|
}]),
|
|
},
|
|
None,
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Helper to create OpenAI streaming chunk
|
|
fn create_openai_chunk(
|
|
id: &str,
|
|
model: &str,
|
|
delta: MessageDelta,
|
|
finish_reason: Option<FinishReason>,
|
|
usage: Option<Usage>,
|
|
) -> ChatCompletionsStreamResponse {
|
|
ChatCompletionsStreamResponse {
|
|
id: id.to_string(),
|
|
object: Some("chat.completion.chunk".to_string()),
|
|
created: current_timestamp(),
|
|
model: model.to_string(),
|
|
choices: vec![StreamChoice {
|
|
index: 0,
|
|
delta,
|
|
finish_reason,
|
|
logprobs: None,
|
|
}],
|
|
usage,
|
|
system_fingerprint: None,
|
|
service_tier: None,
|
|
}
|
|
}
|
|
|
|
/// Helper to create empty OpenAI streaming chunk
|
|
fn create_empty_openai_chunk() -> ChatCompletionsStreamResponse {
|
|
create_openai_chunk(
|
|
"stream",
|
|
"unknown",
|
|
MessageDelta {
|
|
role: None,
|
|
content: None,
|
|
refusal: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// Convert Anthropic content blocks to OpenAI message content
|
|
fn convert_anthropic_content_to_openai(
|
|
content: &[MessagesContentBlock],
|
|
) -> Result<MessageContent, TransformError> {
|
|
let mut text_parts = Vec::new();
|
|
|
|
for block in content {
|
|
match block {
|
|
MessagesContentBlock::Text { text, .. } => {
|
|
text_parts.push(text.clone());
|
|
}
|
|
MessagesContentBlock::Thinking { thinking, .. } => {
|
|
text_parts.push(format!("thinking: {}", thinking));
|
|
}
|
|
_ => {
|
|
// Skip other content types for basic text conversion
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(MessageContent::Text(text_parts.join("\n")))
|
|
}
|
|
|
|
// Stop Reason Conversions
|
|
impl Into<FinishReason> for MessagesStopReason {
|
|
fn into(self) -> FinishReason {
|
|
match self {
|
|
MessagesStopReason::EndTurn => FinishReason::Stop,
|
|
MessagesStopReason::MaxTokens => FinishReason::Length,
|
|
MessagesStopReason::StopSequence => FinishReason::Stop,
|
|
MessagesStopReason::ToolUse => FinishReason::ToolCalls,
|
|
MessagesStopReason::PauseTurn => FinishReason::Stop,
|
|
MessagesStopReason::Refusal => FinishReason::ContentFilter,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert Bedrock Message to OpenAI content and tool calls
|
|
/// This function extracts text content and tool calls from a Bedrock message
|
|
fn convert_bedrock_message_to_openai(
|
|
message: &crate::apis::amazon_bedrock::Message,
|
|
) -> Result<(Option<String>, Option<Vec<crate::apis::openai::ToolCall>>), TransformError> {
|
|
use crate::apis::amazon_bedrock::ContentBlock;
|
|
use crate::apis::openai::{FunctionCall, ToolCall};
|
|
|
|
let mut text_content = String::new();
|
|
let mut tool_calls = Vec::new();
|
|
|
|
for content_block in &message.content {
|
|
match content_block {
|
|
ContentBlock::Text { text } => {
|
|
text_content.push_str(text);
|
|
}
|
|
ContentBlock::ToolUse { tool_use } => {
|
|
tool_calls.push(ToolCall {
|
|
id: tool_use.tool_use_id.clone(),
|
|
call_type: "function".to_string(),
|
|
function: FunctionCall {
|
|
name: tool_use.name.clone(),
|
|
arguments: serde_json::to_string(&tool_use.input).unwrap_or_default(),
|
|
},
|
|
});
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
|
|
let content = if text_content.is_empty() {
|
|
None
|
|
} else {
|
|
Some(text_content)
|
|
};
|
|
let tool_calls = if tool_calls.is_empty() {
|
|
None
|
|
} else {
|
|
Some(tool_calls)
|
|
};
|
|
|
|
Ok((content, tool_calls))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::apis::amazon_bedrock::{
|
|
BedrockTokenUsage, ContentBlock, ConversationRole, ConverseOutput, ConverseResponse,
|
|
ConverseTrace, Message as BedrockMessage, PromptRouterTrace, StopReason,
|
|
};
|
|
use crate::apis::openai::{ChatCompletionsResponse, FinishReason, Role};
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_basic_response() {
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![ContentBlock::Text {
|
|
text: "Hello! How can I help you today?".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
stop_reason: StopReason::EndTurn,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 10,
|
|
output_tokens: 25,
|
|
total_tokens: 35,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
|
|
assert_eq!(openai_response.object, Some("chat.completion".to_string()));
|
|
assert_eq!(openai_response.model, "bedrock-model");
|
|
assert!(openai_response.id.starts_with("bedrock-"));
|
|
|
|
// Check choices
|
|
assert_eq!(openai_response.choices.len(), 1);
|
|
let choice = &openai_response.choices[0];
|
|
assert_eq!(choice.index, 0);
|
|
assert_eq!(choice.message.role, Role::Assistant);
|
|
assert_eq!(
|
|
choice.message.content,
|
|
Some("Hello! How can I help you today?".to_string())
|
|
);
|
|
assert_eq!(choice.finish_reason, Some(FinishReason::Stop));
|
|
assert!(choice.message.tool_calls.is_none());
|
|
|
|
// Check usage
|
|
assert_eq!(openai_response.usage.prompt_tokens, 10);
|
|
assert_eq!(openai_response.usage.completion_tokens, 25);
|
|
assert_eq!(openai_response.usage.total_tokens, 35);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_with_tool_use() {
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![
|
|
ContentBlock::Text {
|
|
text: "I'll help you check the weather.".to_string(),
|
|
},
|
|
ContentBlock::ToolUse {
|
|
tool_use: crate::apis::amazon_bedrock::ToolUseBlock {
|
|
tool_use_id: "tool_12345".to_string(),
|
|
name: "get_weather".to_string(),
|
|
input: json!({
|
|
"location": "San Francisco"
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
stop_reason: StopReason::ToolUse,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 15,
|
|
output_tokens: 30,
|
|
total_tokens: 45,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
|
|
assert_eq!(
|
|
openai_response.choices[0].finish_reason,
|
|
Some(FinishReason::ToolCalls)
|
|
);
|
|
assert_eq!(
|
|
openai_response.choices[0].message.content,
|
|
Some("I'll help you check the weather.".to_string())
|
|
);
|
|
|
|
// Check tool calls
|
|
let tool_calls = openai_response.choices[0]
|
|
.message
|
|
.tool_calls
|
|
.as_ref()
|
|
.unwrap();
|
|
assert_eq!(tool_calls.len(), 1);
|
|
|
|
let tool_call = &tool_calls[0];
|
|
assert_eq!(tool_call.id, "tool_12345");
|
|
assert_eq!(tool_call.call_type, "function");
|
|
assert_eq!(tool_call.function.name, "get_weather");
|
|
|
|
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments).unwrap();
|
|
assert_eq!(args["location"], "San Francisco");
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_stop_reason_conversions() {
|
|
let test_cases = vec![
|
|
(StopReason::EndTurn, FinishReason::Stop),
|
|
(StopReason::ToolUse, FinishReason::ToolCalls),
|
|
(StopReason::MaxTokens, FinishReason::Length),
|
|
(StopReason::StopSequence, FinishReason::Stop),
|
|
(StopReason::GuardrailIntervened, FinishReason::ContentFilter),
|
|
(StopReason::ContentFiltered, FinishReason::ContentFilter),
|
|
];
|
|
|
|
for (bedrock_stop_reason, expected_openai_finish_reason) in test_cases {
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![ContentBlock::Text {
|
|
text: "Test response".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
stop_reason: bedrock_stop_reason,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 5,
|
|
output_tokens: 10,
|
|
total_tokens: 15,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
assert_eq!(
|
|
openai_response.choices[0].finish_reason,
|
|
Some(expected_openai_finish_reason)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_multiple_tool_calls() {
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![
|
|
ContentBlock::Text {
|
|
text: "I'll help with multiple tasks.".to_string(),
|
|
},
|
|
ContentBlock::ToolUse {
|
|
tool_use: crate::apis::amazon_bedrock::ToolUseBlock {
|
|
tool_use_id: "tool_1".to_string(),
|
|
name: "search".to_string(),
|
|
input: json!({"query": "weather"}),
|
|
},
|
|
},
|
|
ContentBlock::ToolUse {
|
|
tool_use: crate::apis::amazon_bedrock::ToolUseBlock {
|
|
tool_use_id: "tool_2".to_string(),
|
|
name: "lookup".to_string(),
|
|
input: json!({"id": "12345"}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
stop_reason: StopReason::ToolUse,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 25,
|
|
output_tokens: 40,
|
|
total_tokens: 65,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
|
|
assert_eq!(
|
|
openai_response.choices[0].finish_reason,
|
|
Some(FinishReason::ToolCalls)
|
|
);
|
|
assert_eq!(
|
|
openai_response.choices[0].message.content,
|
|
Some("I'll help with multiple tasks.".to_string())
|
|
);
|
|
|
|
// Check multiple tool calls
|
|
let tool_calls = openai_response.choices[0]
|
|
.message
|
|
.tool_calls
|
|
.as_ref()
|
|
.unwrap();
|
|
assert_eq!(tool_calls.len(), 2);
|
|
|
|
// First tool call
|
|
assert_eq!(tool_calls[0].id, "tool_1");
|
|
assert_eq!(tool_calls[0].function.name, "search");
|
|
let args1: serde_json::Value =
|
|
serde_json::from_str(&tool_calls[0].function.arguments).unwrap();
|
|
assert_eq!(args1["query"], "weather");
|
|
|
|
// Second tool call
|
|
assert_eq!(tool_calls[1].id, "tool_2");
|
|
assert_eq!(tool_calls[1].function.name, "lookup");
|
|
let args2: serde_json::Value =
|
|
serde_json::from_str(&tool_calls[1].function.arguments).unwrap();
|
|
assert_eq!(args2["id"], "12345");
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_mixed_content() {
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![
|
|
ContentBlock::Text {
|
|
text: "First part. ".to_string(),
|
|
},
|
|
ContentBlock::ToolUse {
|
|
tool_use: crate::apis::amazon_bedrock::ToolUseBlock {
|
|
tool_use_id: "tool_mid".to_string(),
|
|
name: "calculate".to_string(),
|
|
input: json!({"expr": "2+2"}),
|
|
},
|
|
},
|
|
ContentBlock::Text {
|
|
text: "Second part.".to_string(),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
stop_reason: StopReason::ToolUse,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 20,
|
|
output_tokens: 35,
|
|
total_tokens: 55,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
|
|
// Content should be combined text parts (no separator added)
|
|
assert_eq!(
|
|
openai_response.choices[0].message.content,
|
|
Some("First part. Second part.".to_string())
|
|
);
|
|
|
|
// Should have one tool call
|
|
let tool_calls = openai_response.choices[0]
|
|
.message
|
|
.tool_calls
|
|
.as_ref()
|
|
.unwrap();
|
|
assert_eq!(tool_calls.len(), 1);
|
|
assert_eq!(tool_calls[0].id, "tool_mid");
|
|
assert_eq!(tool_calls[0].function.name, "calculate");
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_empty_content() {
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![ContentBlock::ToolUse {
|
|
tool_use: crate::apis::amazon_bedrock::ToolUseBlock {
|
|
tool_use_id: "tool_only".to_string(),
|
|
name: "action".to_string(),
|
|
input: json!({}),
|
|
},
|
|
}],
|
|
},
|
|
},
|
|
stop_reason: StopReason::ToolUse,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
total_tokens: 15,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
|
|
// Content should be None when there's no text
|
|
assert_eq!(openai_response.choices[0].message.content, None);
|
|
|
|
// Should have tool call
|
|
let tool_calls = openai_response.choices[0]
|
|
.message
|
|
.tool_calls
|
|
.as_ref()
|
|
.unwrap();
|
|
assert_eq!(tool_calls.len(), 1);
|
|
assert_eq!(tool_calls[0].id, "tool_only");
|
|
}
|
|
|
|
#[test]
|
|
fn test_convert_bedrock_message_to_openai() {
|
|
let bedrock_message = BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![
|
|
ContentBlock::Text {
|
|
text: "Hello world!".to_string(),
|
|
},
|
|
ContentBlock::ToolUse {
|
|
tool_use: crate::apis::amazon_bedrock::ToolUseBlock {
|
|
tool_use_id: "test_tool".to_string(),
|
|
name: "test_function".to_string(),
|
|
input: json!({"param": "value"}),
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
let (content, tool_calls) = convert_bedrock_message_to_openai(&bedrock_message).unwrap();
|
|
|
|
assert_eq!(content, Some("Hello world!".to_string()));
|
|
|
|
let tool_calls = tool_calls.unwrap();
|
|
assert_eq!(tool_calls.len(), 1);
|
|
assert_eq!(tool_calls[0].id, "test_tool");
|
|
assert_eq!(tool_calls[0].function.name, "test_function");
|
|
|
|
let args: serde_json::Value =
|
|
serde_json::from_str(&tool_calls[0].function.arguments).unwrap();
|
|
assert_eq!(args["param"], "value");
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_role_conversion() {
|
|
// Test Assistant role
|
|
let assistant_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![ContentBlock::Text {
|
|
text: "I am an assistant".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
stop_reason: StopReason::EndTurn,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 5,
|
|
output_tokens: 10,
|
|
total_tokens: 15,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = assistant_response.try_into().unwrap();
|
|
assert_eq!(openai_response.choices[0].message.role, Role::Assistant);
|
|
|
|
// Test User role
|
|
let user_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::User,
|
|
content: vec![ContentBlock::Text {
|
|
text: "I am a user".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
stop_reason: StopReason::EndTurn,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 5,
|
|
output_tokens: 10,
|
|
total_tokens: 15,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = user_response.try_into().unwrap();
|
|
assert_eq!(openai_response.choices[0].message.role, Role::User);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_model_extraction() {
|
|
// Test model extraction from trace information
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![ContentBlock::Text {
|
|
text: "Test response".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
stop_reason: StopReason::EndTurn,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
total_tokens: 15,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: Some(ConverseTrace {
|
|
guardrail: None,
|
|
prompt_router: Some(PromptRouterTrace {
|
|
invoked_model_id: "anthropic.claude-3-sonnet-20240229-v1:0".to_string(),
|
|
}),
|
|
}),
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
|
|
// Should extract model ID from trace
|
|
assert_eq!(
|
|
openai_response.model,
|
|
"anthropic.claude-3-sonnet-20240229-v1:0"
|
|
);
|
|
|
|
// Test fallback when no trace information is available
|
|
let bedrock_response_no_trace = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![ContentBlock::Text {
|
|
text: "Test response".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
stop_reason: StopReason::EndTurn,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
total_tokens: 15,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response_fallback: ChatCompletionsResponse =
|
|
bedrock_response_no_trace.try_into().unwrap();
|
|
|
|
// Should use fallback model name
|
|
assert_eq!(openai_response_fallback.model, "bedrock-model");
|
|
}
|
|
|
|
#[test]
|
|
fn test_bedrock_to_openai_with_multimedia_content() {
|
|
use crate::apis::amazon_bedrock::ImageSource;
|
|
|
|
let bedrock_response = ConverseResponse {
|
|
output: ConverseOutput::Message {
|
|
message: BedrockMessage {
|
|
role: ConversationRole::Assistant,
|
|
content: vec![
|
|
ContentBlock::Text {
|
|
text: "Here's the analysis:".to_string(),
|
|
},
|
|
ContentBlock::Image {
|
|
image: crate::apis::amazon_bedrock::ImageBlock {
|
|
source: ImageSource::Base64 {
|
|
media_type: "image/jpeg".to_string(),
|
|
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==".to_string(),
|
|
},
|
|
},
|
|
}
|
|
],
|
|
},
|
|
},
|
|
stop_reason: StopReason::EndTurn,
|
|
usage: BedrockTokenUsage {
|
|
input_tokens: 50,
|
|
output_tokens: 75,
|
|
total_tokens: 125,
|
|
..Default::default()
|
|
},
|
|
metrics: None,
|
|
trace: None,
|
|
additional_model_response_fields: None,
|
|
performance_config: None,
|
|
};
|
|
|
|
let openai_response: ChatCompletionsResponse = bedrock_response.try_into().unwrap();
|
|
|
|
assert_eq!(
|
|
openai_response.choices[0].finish_reason,
|
|
Some(FinishReason::Stop)
|
|
);
|
|
|
|
let content = openai_response.choices[0].message.content.as_ref().unwrap();
|
|
|
|
// Check that text content is preserved (image blocks are currently ignored)
|
|
assert!(content.contains("Here's the analysis:"));
|
|
// Note: Image blocks are not converted to text in the current implementation
|
|
}
|
|
|
|
#[test]
|
|
fn test_chat_completions_to_responses_api_basic() {
|
|
use crate::apis::openai_responses::{OutputContent, OutputItem, ResponsesAPIResponse};
|
|
|
|
let chat_response = ChatCompletionsResponse {
|
|
id: "chatcmpl-123".to_string(),
|
|
object: Some("chat.completion".to_string()),
|
|
created: 1677652288,
|
|
model: "gpt-4".to_string(),
|
|
choices: vec![Choice {
|
|
index: 0,
|
|
message: crate::apis::openai::ResponseMessage {
|
|
role: Role::Assistant,
|
|
content: Some("Hello! How can I help you?".to_string()),
|
|
refusal: None,
|
|
annotations: None,
|
|
audio: None,
|
|
function_call: None,
|
|
tool_calls: None,
|
|
},
|
|
finish_reason: Some(FinishReason::Stop),
|
|
logprobs: None,
|
|
}],
|
|
usage: Usage {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
prompt_tokens_details: None,
|
|
completion_tokens_details: None,
|
|
},
|
|
system_fingerprint: None,
|
|
service_tier: Some("default".to_string()),
|
|
metadata: None,
|
|
};
|
|
|
|
let responses_api: ResponsesAPIResponse = chat_response.try_into().unwrap();
|
|
|
|
assert_eq!(responses_api.id, "chatcmpl-123");
|
|
assert_eq!(responses_api.object, "response");
|
|
assert_eq!(responses_api.model, "gpt-4");
|
|
|
|
// Check usage conversion
|
|
let usage = responses_api.usage.unwrap();
|
|
assert_eq!(usage.input_tokens, 10);
|
|
assert_eq!(usage.output_tokens, 20);
|
|
assert_eq!(usage.total_tokens, 30);
|
|
|
|
// Check output items
|
|
assert_eq!(responses_api.output.len(), 1);
|
|
match &responses_api.output[0] {
|
|
OutputItem::Message {
|
|
role,
|
|
content,
|
|
..
|
|
} => {
|
|
assert_eq!(role, "assistant");
|
|
assert_eq!(content.len(), 1);
|
|
match &content[0] {
|
|
OutputContent::OutputText { text, .. } => {
|
|
assert_eq!(text, "Hello! How can I help you?");
|
|
}
|
|
_ => panic!("Expected OutputText content"),
|
|
}
|
|
}
|
|
_ => panic!("Expected Message output item"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_chat_completions_to_responses_api_with_tool_calls() {
|
|
use crate::apis::openai::{FunctionCall, ToolCall};
|
|
use crate::apis::openai_responses::{OutputItem, ResponsesAPIResponse};
|
|
|
|
let chat_response = ChatCompletionsResponse {
|
|
id: "chatcmpl-456".to_string(),
|
|
object: Some("chat.completion".to_string()),
|
|
created: 1677652300,
|
|
model: "gpt-4".to_string(),
|
|
choices: vec![Choice {
|
|
index: 0,
|
|
message: crate::apis::openai::ResponseMessage {
|
|
role: Role::Assistant,
|
|
content: Some("Let me check the weather.".to_string()),
|
|
refusal: None,
|
|
annotations: None,
|
|
audio: None,
|
|
function_call: None,
|
|
tool_calls: Some(vec![ToolCall {
|
|
id: "call_abc123".to_string(),
|
|
call_type: "function".to_string(),
|
|
function: FunctionCall {
|
|
name: "get_weather".to_string(),
|
|
arguments: r#"{"location":"San Francisco"}"#.to_string(),
|
|
},
|
|
}]),
|
|
},
|
|
finish_reason: Some(FinishReason::ToolCalls),
|
|
logprobs: None,
|
|
}],
|
|
usage: Usage {
|
|
prompt_tokens: 15,
|
|
completion_tokens: 25,
|
|
total_tokens: 40,
|
|
prompt_tokens_details: None,
|
|
completion_tokens_details: None,
|
|
},
|
|
system_fingerprint: None,
|
|
service_tier: None,
|
|
metadata: None,
|
|
};
|
|
|
|
let responses_api: ResponsesAPIResponse = chat_response.try_into().unwrap();
|
|
|
|
// Should have 2 output items: message + function call
|
|
assert_eq!(responses_api.output.len(), 2);
|
|
|
|
// Check message item
|
|
match &responses_api.output[0] {
|
|
OutputItem::Message { content, .. } => {
|
|
assert_eq!(content.len(), 1);
|
|
}
|
|
_ => panic!("Expected Message output item"),
|
|
}
|
|
|
|
// Check function call item
|
|
match &responses_api.output[1] {
|
|
OutputItem::FunctionCall {
|
|
call_id,
|
|
name,
|
|
arguments,
|
|
..
|
|
} => {
|
|
assert_eq!(call_id, "call_abc123");
|
|
assert_eq!(name.as_ref().unwrap(), "get_weather");
|
|
assert!(arguments.as_ref().unwrap().contains("San Francisco"));
|
|
}
|
|
_ => panic!("Expected FunctionCall output item"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_chat_completions_to_responses_api_tool_calls_only() {
|
|
use crate::apis::openai::{FunctionCall, ToolCall};
|
|
use crate::apis::openai_responses::{OutputItem, ResponsesAPIResponse};
|
|
|
|
// Test the real-world case where content is null and there are only tool calls
|
|
let chat_response = ChatCompletionsResponse {
|
|
id: "chatcmpl-789".to_string(),
|
|
object: Some("chat.completion".to_string()),
|
|
created: 1764023939,
|
|
model: "gpt-4o-2024-08-06".to_string(),
|
|
choices: vec![Choice {
|
|
index: 0,
|
|
message: crate::apis::openai::ResponseMessage {
|
|
role: Role::Assistant,
|
|
content: None, // No text content, only tool calls
|
|
refusal: None,
|
|
annotations: None,
|
|
audio: None,
|
|
function_call: None,
|
|
tool_calls: Some(vec![ToolCall {
|
|
id: "call_oJBtqTJmRfBGlFS55QhMfUUV".to_string(),
|
|
call_type: "function".to_string(),
|
|
function: FunctionCall {
|
|
name: "get_weather".to_string(),
|
|
arguments: r#"{"location":"San Francisco, CA"}"#.to_string(),
|
|
},
|
|
}]),
|
|
},
|
|
finish_reason: Some(FinishReason::ToolCalls),
|
|
logprobs: None,
|
|
}],
|
|
usage: Usage {
|
|
prompt_tokens: 84,
|
|
completion_tokens: 17,
|
|
total_tokens: 101,
|
|
prompt_tokens_details: None,
|
|
completion_tokens_details: None,
|
|
},
|
|
system_fingerprint: Some("fp_7eeb46f068".to_string()),
|
|
service_tier: Some("default".to_string()),
|
|
metadata: None,
|
|
};
|
|
|
|
let responses_api: ResponsesAPIResponse = chat_response.try_into().unwrap();
|
|
|
|
// Should have only 1 output item: function call (no empty message item)
|
|
assert_eq!(responses_api.output.len(), 1);
|
|
|
|
// Check function call item
|
|
match &responses_api.output[0] {
|
|
OutputItem::FunctionCall {
|
|
call_id,
|
|
name,
|
|
arguments,
|
|
..
|
|
} => {
|
|
assert_eq!(call_id, "call_oJBtqTJmRfBGlFS55QhMfUUV");
|
|
assert_eq!(name.as_ref().unwrap(), "get_weather");
|
|
assert!(arguments.as_ref().unwrap().contains("San Francisco, CA"));
|
|
}
|
|
_ => panic!("Expected FunctionCall output item as first item"),
|
|
}
|
|
|
|
// Verify status is Completed for tool_calls finish reason
|
|
assert!(matches!(responses_api.status, crate::apis::openai_responses::ResponseStatus::Completed));
|
|
}
|
|
}
|