mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
shrink formats conversion to compatibility facades
This commit is contained in:
@@ -727,7 +727,7 @@ pub(crate) fn gemini_part_to_canonical_block(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.unwrap_or_else(|| format!("toolu_{name}_{index}"));
|
.unwrap_or_else(|| format!("call_auto_{index}"));
|
||||||
return Some(CanonicalContentBlock::ToolUse {
|
return Some(CanonicalContentBlock::ToolUse {
|
||||||
id,
|
id,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -2206,7 +2206,7 @@ pub(crate) fn flush_openai_responses_message_item(
|
|||||||
"id": id,
|
"id": id,
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"content": std::mem::take(message_content),
|
"content": coalesce_openai_responses_text_content(std::mem::take(message_content)),
|
||||||
}));
|
}));
|
||||||
*message_index += 1;
|
*message_index += 1;
|
||||||
}
|
}
|
||||||
@@ -2224,9 +2224,64 @@ pub(crate) fn openai_content_value_from_parts(parts: Vec<Value>, tool_only: bool
|
|||||||
return Value::String(text.to_string());
|
return Value::String(text.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if parts.iter().all(|part| {
|
||||||
|
part.as_object()
|
||||||
|
.and_then(|object| object.get("text"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some()
|
||||||
|
&& part
|
||||||
|
.as_object()
|
||||||
|
.and_then(|object| object.get("type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_none_or(|part_type| part_type == "text")
|
||||||
|
}) {
|
||||||
|
return Value::String(
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| {
|
||||||
|
part.as_object()
|
||||||
|
.and_then(|object| object.get("text"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
}
|
||||||
Value::Array(parts)
|
Value::Array(parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn coalesce_openai_responses_text_content(content: Vec<Value>) -> Vec<Value> {
|
||||||
|
if content.len() <= 1 {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut annotations = Vec::new();
|
||||||
|
for part in &content {
|
||||||
|
let Some(part_object) = part.as_object() else {
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
let part_type = part_object
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !matches!(part_type, "output_text" | "text") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
let Some(part_text) = part_object.get("text").and_then(Value::as_str) else {
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
text.push_str(part_text);
|
||||||
|
if let Some(part_annotations) = part_object.get("annotations").and_then(Value::as_array) {
|
||||||
|
annotations.extend(part_annotations.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![json!({
|
||||||
|
"type": "output_text",
|
||||||
|
"text": text,
|
||||||
|
"annotations": annotations,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn openai_content_text(content: Option<&Value>) -> String {
|
pub(crate) fn openai_content_text(content: Option<&Value>) -> String {
|
||||||
match content {
|
match content {
|
||||||
Some(Value::String(text)) => text.clone(),
|
Some(Value::String(text)) => text.clone(),
|
||||||
@@ -3376,6 +3431,17 @@ pub(crate) fn openai_usage_to_canonical(value: Option<&Value>) -> Option<Canonic
|
|||||||
.and_then(|details| details.get("cached_tokens"))
|
.and_then(|details| details.get("cached_tokens"))
|
||||||
.and_then(Value::as_u64)
|
.and_then(Value::as_u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let cache_write_tokens = usage
|
||||||
|
.get("prompt_tokens_details")
|
||||||
|
.or_else(|| usage.get("input_tokens_details"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|details| {
|
||||||
|
details
|
||||||
|
.get("cached_creation_tokens")
|
||||||
|
.or_else(|| details.get("cache_creation_tokens"))
|
||||||
|
})
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0);
|
||||||
Some(CanonicalUsage {
|
Some(CanonicalUsage {
|
||||||
input_tokens,
|
input_tokens,
|
||||||
output_tokens,
|
output_tokens,
|
||||||
@@ -3384,6 +3450,7 @@ pub(crate) fn openai_usage_to_canonical(value: Option<&Value>) -> Option<Canonic
|
|||||||
.and_then(Value::as_u64)
|
.and_then(Value::as_u64)
|
||||||
.unwrap_or(input_tokens + output_tokens),
|
.unwrap_or(input_tokens + output_tokens),
|
||||||
cache_read_tokens,
|
cache_read_tokens,
|
||||||
|
cache_write_tokens,
|
||||||
reasoning_tokens,
|
reasoning_tokens,
|
||||||
extensions: openai_extensions(
|
extensions: openai_extensions(
|
||||||
usage,
|
usage,
|
||||||
|
|||||||
@@ -1,814 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
|
|
||||||
use super::shared::parse_openai_tool_arguments;
|
|
||||||
use crate::planner::openai::{
|
|
||||||
copy_request_number_field, extract_openai_reasoning_effort,
|
|
||||||
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_thinking_budget,
|
|
||||||
parse_openai_stop_sequences, resolve_openai_chat_max_tokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn convert_openai_chat_request_to_claude_request(
|
|
||||||
body_json: &Value,
|
|
||||||
mapped_model: &str,
|
|
||||||
upstream_is_stream: bool,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let request = body_json.as_object()?;
|
|
||||||
let mut system_segments = Vec::new();
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
|
|
||||||
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
|
|
||||||
for message in message_values {
|
|
||||||
let message_object = message.as_object()?;
|
|
||||||
let role = message_object
|
|
||||||
.get("role")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match role.as_str() {
|
|
||||||
"system" | "developer" => {
|
|
||||||
let text = extract_openai_text_content(message_object.get("content"))?;
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
system_segments.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"user" => {
|
|
||||||
let blocks = convert_openai_content_to_claude_blocks(
|
|
||||||
message_object.get("content"),
|
|
||||||
ClaudeMessageRole::User,
|
|
||||||
)?;
|
|
||||||
if !blocks.is_empty() {
|
|
||||||
messages.push(build_claude_message("user", blocks));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"assistant" => {
|
|
||||||
let mut blocks = extract_openai_reasoning_to_claude_blocks(message_object);
|
|
||||||
blocks.extend(convert_openai_content_to_claude_blocks(
|
|
||||||
message_object.get("content"),
|
|
||||||
ClaudeMessageRole::Assistant,
|
|
||||||
)?);
|
|
||||||
if let Some(tool_calls) =
|
|
||||||
message_object.get("tool_calls").and_then(Value::as_array)
|
|
||||||
{
|
|
||||||
for tool_call in tool_calls {
|
|
||||||
let tool_call_object = tool_call.as_object()?;
|
|
||||||
let function = tool_call_object.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_string();
|
|
||||||
let tool_call_id = tool_call_object
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
|
|
||||||
let tool_input =
|
|
||||||
parse_openai_tool_arguments(function.get("arguments"))?;
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": tool_call_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"input": tool_input,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !blocks.is_empty() {
|
|
||||||
messages.push(build_claude_message("assistant", blocks));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tool" => {
|
|
||||||
let tool_use_id = message_object
|
|
||||||
.get("tool_call_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_string();
|
|
||||||
let tool_result =
|
|
||||||
parse_openai_tool_result_content(message_object.get("content"));
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "user",
|
|
||||||
"content": [{
|
|
||||||
"type": "tool_result",
|
|
||||||
"tool_use_id": tool_use_id,
|
|
||||||
"content": tool_result,
|
|
||||||
"is_error": false,
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut output = Map::new();
|
|
||||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
|
||||||
output.insert(
|
|
||||||
"messages".to_string(),
|
|
||||||
Value::Array(compact_claude_messages(messages)),
|
|
||||||
);
|
|
||||||
output.insert(
|
|
||||||
"max_tokens".to_string(),
|
|
||||||
Value::from(resolve_openai_chat_max_tokens(request)),
|
|
||||||
);
|
|
||||||
|
|
||||||
let system_text = system_segments
|
|
||||||
.into_iter()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n\n");
|
|
||||||
if !system_text.is_empty() {
|
|
||||||
output.insert("system".to_string(), Value::String(system_text));
|
|
||||||
}
|
|
||||||
if upstream_is_stream {
|
|
||||||
output.insert("stream".to_string(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
copy_request_number_field(request, &mut output, "temperature");
|
|
||||||
copy_request_number_field(request, &mut output, "top_p");
|
|
||||||
copy_request_number_field(request, &mut output, "top_k");
|
|
||||||
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
|
|
||||||
output.insert("stop_sequences".to_string(), Value::Array(stop_sequences));
|
|
||||||
}
|
|
||||||
if let Some(tools) =
|
|
||||||
convert_openai_tools_to_claude(request.get("tools"), request.get("web_search_options"))
|
|
||||||
{
|
|
||||||
output.insert("tools".to_string(), Value::Array(tools));
|
|
||||||
}
|
|
||||||
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(
|
|
||||||
request.get("tool_choice"),
|
|
||||||
request.get("parallel_tool_calls"),
|
|
||||||
) {
|
|
||||||
output.insert("tool_choice".to_string(), tool_choice);
|
|
||||||
}
|
|
||||||
if let Some(metadata) = request.get("metadata").cloned() {
|
|
||||||
output.insert("metadata".to_string(), metadata);
|
|
||||||
}
|
|
||||||
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
|
|
||||||
if let Some(thinking_budget) =
|
|
||||||
map_openai_reasoning_effort_to_thinking_budget(reasoning_effort.as_str())
|
|
||||||
{
|
|
||||||
output.insert(
|
|
||||||
"thinking".to_string(),
|
|
||||||
json!({
|
|
||||||
"type": "enabled",
|
|
||||||
"budget_tokens": thinking_budget,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(output_effort) =
|
|
||||||
map_openai_reasoning_effort_to_claude_output(reasoning_effort.as_str())
|
|
||||||
{
|
|
||||||
output.insert(
|
|
||||||
"output_config".to_string(),
|
|
||||||
json!({
|
|
||||||
"effort": output_effort,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Value::Object(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum ClaudeMessageRole {
|
|
||||||
User,
|
|
||||||
Assistant,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_openai_content_to_claude_blocks(
|
|
||||||
content: Option<&Value>,
|
|
||||||
role: ClaudeMessageRole,
|
|
||||||
) -> Option<Vec<Value>> {
|
|
||||||
match content {
|
|
||||||
None | Some(Value::Null) => Some(Vec::new()),
|
|
||||||
Some(Value::String(text)) => {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
Some(Vec::new())
|
|
||||||
} else {
|
|
||||||
Some(vec![json!({ "type": "text", "text": text })])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Array(parts)) => {
|
|
||||||
let mut blocks = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part_object = part.as_object()?;
|
|
||||||
let part_type = part_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
match part_type {
|
|
||||||
"text" | "input_text" | "output_text" => {
|
|
||||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
blocks.push(json!({ "type": "text", "text": text }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"image_url" | "input_image" | "output_image" => {
|
|
||||||
let url = part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(|value| {
|
|
||||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
|
||||||
value
|
|
||||||
.as_object()
|
|
||||||
.and_then(|object| object.get("url"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
part_object
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
.filter(|value| !value.trim().is_empty())?;
|
|
||||||
if role == ClaudeMessageRole::User {
|
|
||||||
if let Some((media_type, data)) = parse_data_url(url.as_str()) {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": media_type,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "url",
|
|
||||||
"url": url,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": assistant_image_placeholder(url.as_str()),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"file" | "input_file" => {
|
|
||||||
let file_object = part_object
|
|
||||||
.get("file")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part_object);
|
|
||||||
if let Some(file_data) =
|
|
||||||
file_object.get("file_data").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
if let Some((media_type, data)) = parse_data_url(file_data) {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": media_type,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if let Some(file_id) = file_object
|
|
||||||
.get("file_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": format!("[File: {file_id}]"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"input_audio" => {
|
|
||||||
let audio_object = part_object
|
|
||||||
.get("input_audio")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part_object);
|
|
||||||
let data = audio_object
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let format = audio_object
|
|
||||||
.get("format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
if let (Some(data), Some(format)) = (data, format) {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": format!("audio/{format}"),
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(blocks)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_reasoning_to_claude_blocks(message: &Map<String, Value>) -> Vec<Value> {
|
|
||||||
let mut blocks = Vec::new();
|
|
||||||
if let Some(reasoning_parts) = message.get("reasoning_parts").and_then(Value::as_array) {
|
|
||||||
for reasoning_part in reasoning_parts {
|
|
||||||
let Some(reasoning_object) = reasoning_part.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match reasoning_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("thinking")
|
|
||||||
{
|
|
||||||
"thinking" => {
|
|
||||||
let thinking = reasoning_object
|
|
||||||
.get("thinking")
|
|
||||||
.or_else(|| reasoning_object.get("text"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if thinking.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut block = Map::new();
|
|
||||||
block.insert("type".to_string(), Value::String("thinking".to_string()));
|
|
||||||
block.insert("thinking".to_string(), Value::String(thinking.to_string()));
|
|
||||||
if let Some(signature) = reasoning_object
|
|
||||||
.get("signature")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
block.insert(
|
|
||||||
"signature".to_string(),
|
|
||||||
Value::String(signature.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
blocks.push(Value::Object(block));
|
|
||||||
}
|
|
||||||
"redacted_thinking" => {
|
|
||||||
let data = reasoning_object
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !data.is_empty() {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !blocks.is_empty() {
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
if let Some(reasoning_content) = message
|
|
||||||
.get("reasoning_content")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": reasoning_content,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_openai_tools_to_claude(
|
|
||||||
tools: Option<&Value>,
|
|
||||||
web_search_options: Option<&Value>,
|
|
||||||
) -> Option<Vec<Value>> {
|
|
||||||
let mut converted = Vec::new();
|
|
||||||
if let Some(tool_values) = tools.and_then(Value::as_array) {
|
|
||||||
for tool in tool_values {
|
|
||||||
let tool_object = tool.as_object()?;
|
|
||||||
if tool_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value != "function")
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let function = tool_object.get("function")?.as_object()?;
|
|
||||||
let name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let mut converted_tool = Map::new();
|
|
||||||
converted_tool.insert("name".to_string(), Value::String(name.to_string()));
|
|
||||||
if let Some(description) = function.get("description").cloned() {
|
|
||||||
converted_tool.insert("description".to_string(), description);
|
|
||||||
}
|
|
||||||
converted_tool.insert(
|
|
||||||
"input_schema".to_string(),
|
|
||||||
function
|
|
||||||
.get("parameters")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| json!({})),
|
|
||||||
);
|
|
||||||
converted.push(Value::Object(converted_tool));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(web_search_tool) =
|
|
||||||
convert_openai_web_search_options_to_claude_tool(web_search_options)
|
|
||||||
{
|
|
||||||
converted.push(web_search_tool);
|
|
||||||
}
|
|
||||||
(!converted.is_empty()).then_some(converted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_openai_web_search_options_to_claude_tool(
|
|
||||||
web_search_options: Option<&Value>,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let web_search_options = web_search_options?.as_object()?;
|
|
||||||
let mut tool = Map::new();
|
|
||||||
tool.insert(
|
|
||||||
"type".to_string(),
|
|
||||||
Value::String("web_search_20250305".to_string()),
|
|
||||||
);
|
|
||||||
tool.insert("name".to_string(), Value::String("web_search".to_string()));
|
|
||||||
if let Some(user_location) = web_search_options
|
|
||||||
.get("user_location")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let approximate = user_location
|
|
||||||
.get("approximate")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(user_location);
|
|
||||||
let mut location = Map::new();
|
|
||||||
location.insert("type".to_string(), Value::String("approximate".to_string()));
|
|
||||||
for field in ["city", "country", "region", "timezone"] {
|
|
||||||
if let Some(value) = approximate.get(field).cloned() {
|
|
||||||
location.insert(field.to_string(), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if location.len() > 1 {
|
|
||||||
tool.insert("user_location".to_string(), Value::Object(location));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(max_uses) = web_search_options
|
|
||||||
.get("search_context_size")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"low" => Some(1u64),
|
|
||||||
"medium" => Some(5u64),
|
|
||||||
"high" => Some(10u64),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
{
|
|
||||||
tool.insert("max_uses".to_string(), Value::from(max_uses));
|
|
||||||
}
|
|
||||||
Some(Value::Object(tool))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_openai_tool_choice_to_claude(
|
|
||||||
tool_choice: Option<&Value>,
|
|
||||||
parallel_tool_calls: Option<&Value>,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let mut converted = match tool_choice {
|
|
||||||
Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"none" => Some(json!({ "type": "none" })),
|
|
||||||
"required" => Some(json!({ "type": "any" })),
|
|
||||||
"auto" => Some(json!({ "type": "auto" })),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
Some(Value::Object(object)) => {
|
|
||||||
let function_name = object
|
|
||||||
.get("function")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|function| function.get("name"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "tool",
|
|
||||||
"name": function_name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Some(_) => None,
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
if let Some(parallel_tool_calls) = parallel_tool_calls.and_then(Value::as_bool) {
|
|
||||||
if converted.is_none() {
|
|
||||||
converted = Some(json!({ "type": "auto" }));
|
|
||||||
}
|
|
||||||
if let Some(object) = converted.as_mut().and_then(Value::as_object_mut) {
|
|
||||||
let choice_type = object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if choice_type != "none" {
|
|
||||||
object.insert(
|
|
||||||
"disable_parallel_tool_use".to_string(),
|
|
||||||
Value::Bool(!parallel_tool_calls),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
converted
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compact_claude_messages(messages: Vec<Value>) -> Vec<Value> {
|
|
||||||
let mut compact: Vec<Value> = Vec::new();
|
|
||||||
for message in messages {
|
|
||||||
let role = message
|
|
||||||
.get("role")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
if let Some(last) = compact.last_mut() {
|
|
||||||
let last_role = last
|
|
||||||
.get("role")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
if last_role == role {
|
|
||||||
merge_claude_message_content(last, message);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compact.push(message);
|
|
||||||
}
|
|
||||||
if compact
|
|
||||||
.first()
|
|
||||||
.and_then(|value| value.get("role"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value == "assistant")
|
|
||||||
{
|
|
||||||
compact.insert(0, json!({ "role": "user", "content": "" }));
|
|
||||||
}
|
|
||||||
compact
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_claude_message_content(target: &mut Value, message: Value) {
|
|
||||||
let Some(target_object) = target.as_object_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let incoming_content = message.get("content").cloned().unwrap_or(Value::Null);
|
|
||||||
let merged_blocks = extract_claude_content_blocks(target_object.get("content"))
|
|
||||||
.into_iter()
|
|
||||||
.chain(extract_claude_content_blocks(Some(&incoming_content)))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
target_object.insert(
|
|
||||||
"content".to_string(),
|
|
||||||
simplify_claude_content(merged_blocks),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_claude_message(role: &str, blocks: Vec<Value>) -> Value {
|
|
||||||
json!({
|
|
||||||
"role": role,
|
|
||||||
"content": simplify_claude_content(blocks),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn simplify_claude_content(blocks: Vec<Value>) -> Value {
|
|
||||||
if blocks.is_empty() {
|
|
||||||
return Value::String(String::new());
|
|
||||||
}
|
|
||||||
let mut text_values = Vec::new();
|
|
||||||
for block in &blocks {
|
|
||||||
let Some(block_object) = block.as_object() else {
|
|
||||||
return Value::Array(blocks);
|
|
||||||
};
|
|
||||||
if block_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value == "text")
|
|
||||||
{
|
|
||||||
if let Some(text) = block_object.get("text").and_then(Value::as_str) {
|
|
||||||
text_values.push(text.to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Value::Array(blocks);
|
|
||||||
}
|
|
||||||
Value::String(text_values.join("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_claude_content_blocks(content: Option<&Value>) -> Vec<Value> {
|
|
||||||
match content {
|
|
||||||
Some(Value::String(text)) if !text.is_empty() => vec![json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
})],
|
|
||||||
Some(Value::Array(blocks)) => blocks.clone(),
|
|
||||||
_ => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
|
||||||
let rest = value.strip_prefix("data:")?;
|
|
||||||
let (meta, data) = rest.split_once(",")?;
|
|
||||||
let media_type = meta.strip_suffix(";base64")?;
|
|
||||||
if media_type.trim().is_empty() || data.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some((media_type.to_string(), data.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assistant_image_placeholder(url: &str) -> String {
|
|
||||||
if url.starts_with("data:") {
|
|
||||||
"[Image]".to_string()
|
|
||||||
} else {
|
|
||||||
format!("[Image: {url}]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_openai_chat_request_to_claude_request;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maps_openai_web_search_options_to_claude_builtin_tool() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5.4",
|
|
||||||
"messages": [
|
|
||||||
{ "role": "user", "content": "weather in shanghai" }
|
|
||||||
],
|
|
||||||
"web_search_options": {
|
|
||||||
"search_context_size": "medium",
|
|
||||||
"user_location": {
|
|
||||||
"approximate": {
|
|
||||||
"city": "Shanghai",
|
|
||||||
"country": "CN",
|
|
||||||
"timezone": "Asia/Shanghai"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted =
|
|
||||||
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(converted["tools"][0]["type"], "web_search_20250305");
|
|
||||||
assert_eq!(converted["tools"][0]["name"], "web_search");
|
|
||||||
assert_eq!(converted["tools"][0]["max_uses"], 5);
|
|
||||||
assert_eq!(
|
|
||||||
converted["tools"][0]["user_location"],
|
|
||||||
json!({
|
|
||||||
"type": "approximate",
|
|
||||||
"city": "Shanghai",
|
|
||||||
"country": "CN",
|
|
||||||
"timezone": "Asia/Shanghai",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maps_parallel_tool_calls_to_disable_parallel_tool_use() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5.4",
|
|
||||||
"messages": [
|
|
||||||
{ "role": "user", "content": "call tools if needed" }
|
|
||||||
],
|
|
||||||
"parallel_tool_calls": true
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted =
|
|
||||||
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["tool_choice"],
|
|
||||||
json!({
|
|
||||||
"type": "auto",
|
|
||||||
"disable_parallel_tool_use": false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_openai_multipart_reasoning_and_file_id_to_claude_request() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5.4",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{ "type": "text", "text": "Read this" },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": "data:image/png;base64,iVBORw0KGgo="
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"reasoning_content": "step by step",
|
|
||||||
"reasoning_parts": [
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "step by step",
|
|
||||||
"signature": "sig_123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": "redacted_blob"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": "https://example.com/diagram.png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ "type": "file", "file": { "file_id": "file_123" } },
|
|
||||||
{ "type": "text", "text": "done" }
|
|
||||||
],
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": "call_1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "lookup",
|
|
||||||
"arguments": "\"tokyo\""
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"reasoning_effort": "xhigh"
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted =
|
|
||||||
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["messages"][0]["content"],
|
|
||||||
json!([
|
|
||||||
{ "type": "text", "text": "Read this" },
|
|
||||||
{
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": "image/png",
|
|
||||||
"data": "iVBORw0KGgo="
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": "application/pdf",
|
|
||||||
"data": "JVBERi0x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(converted["messages"][1]["content"][0]["type"], "thinking");
|
|
||||||
assert_eq!(
|
|
||||||
converted["messages"][1]["content"][0]["signature"],
|
|
||||||
"sig_123"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["messages"][1]["content"][1]["type"],
|
|
||||||
"redacted_thinking"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["messages"][1]["content"][2]["text"],
|
|
||||||
"[Image: https://example.com/diagram.png]"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["messages"][1]["content"][3]["text"],
|
|
||||||
"[File: file_123]"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["messages"][1]["content"][5]["input"],
|
|
||||||
json!({"raw": "tokyo"})
|
|
||||||
);
|
|
||||||
assert_eq!(converted["thinking"]["budget_tokens"], 8192);
|
|
||||||
assert_eq!(converted["output_config"]["effort"], "max");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
mod claude;
|
|
||||||
mod gemini;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub use claude::convert_openai_chat_request_to_claude_request;
|
|
||||||
pub use gemini::convert_openai_chat_request_to_gemini_request;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
pub(super) fn parse_openai_tool_arguments(arguments: Option<&Value>) -> Option<Value> {
|
|
||||||
match arguments {
|
|
||||||
Some(Value::Object(object)) => Some(Value::Object(object.clone())),
|
|
||||||
Some(Value::String(raw)) => {
|
|
||||||
let trimmed = raw.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
Some(json!({}))
|
|
||||||
} else {
|
|
||||||
match serde_json::from_str::<Value>(trimmed) {
|
|
||||||
Ok(Value::Object(object)) => Some(Value::Object(object)),
|
|
||||||
Ok(other) => Some(json!({ "raw": other })),
|
|
||||||
Err(_) => Some(json!({ "raw": trimmed })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(other) => Some(json!({ "raw": other })),
|
|
||||||
None => Some(json!({})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,18 +3,221 @@
|
|||||||
//! New request routing should use the registry so every conversion passes
|
//! New request routing should use the registry so every conversion passes
|
||||||
//! through the typed canonical IR.
|
//! through the typed canonical IR.
|
||||||
|
|
||||||
pub mod from_openai_chat;
|
//! Legacy request conversion function names.
|
||||||
pub mod openai_responses;
|
//!
|
||||||
pub mod to_openai_chat;
|
//! This module is intentionally a compatibility facade. Real wire-format
|
||||||
|
//! parsing and emitting lives under `formats::<format>::request`, and all
|
||||||
|
//! conversion goes through the registry's canonical IR path.
|
||||||
|
|
||||||
pub use from_openai_chat::{
|
use serde_json::Value;
|
||||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
|
||||||
};
|
use crate::{context::FormatContext, registry};
|
||||||
pub use openai_responses::{
|
|
||||||
convert_openai_chat_request_to_openai_responses_request,
|
pub fn convert_openai_chat_request_to_claude_request(
|
||||||
normalize_openai_responses_request_to_openai_chat_request,
|
body_json: &Value,
|
||||||
};
|
mapped_model: &str,
|
||||||
pub use to_openai_chat::{
|
upstream_is_stream: bool,
|
||||||
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
|
) -> Option<Value> {
|
||||||
normalize_gemini_request_to_openai_chat_request, parse_openai_tool_result_content,
|
registry::convert_request(
|
||||||
};
|
"openai:chat",
|
||||||
|
"claude:messages",
|
||||||
|
body_json,
|
||||||
|
&request_context(mapped_model, upstream_is_stream),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_openai_chat_request_to_gemini_request(
|
||||||
|
body_json: &Value,
|
||||||
|
mapped_model: &str,
|
||||||
|
upstream_is_stream: bool,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_request(
|
||||||
|
"openai:chat",
|
||||||
|
"gemini:generate_content",
|
||||||
|
body_json,
|
||||||
|
&request_context(mapped_model, upstream_is_stream),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_openai_chat_request_to_openai_responses_request(
|
||||||
|
body_json: &Value,
|
||||||
|
mapped_model: &str,
|
||||||
|
upstream_is_stream: bool,
|
||||||
|
compact: bool,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let target_format = if compact {
|
||||||
|
"openai:responses:compact"
|
||||||
|
} else {
|
||||||
|
"openai:responses"
|
||||||
|
};
|
||||||
|
registry::convert_request(
|
||||||
|
"openai:chat",
|
||||||
|
target_format,
|
||||||
|
body_json,
|
||||||
|
&request_context(mapped_model, upstream_is_stream),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_openai_responses_request_to_openai_chat_request(
|
||||||
|
body_json: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_request(
|
||||||
|
"openai:responses",
|
||||||
|
"openai:chat",
|
||||||
|
body_json,
|
||||||
|
&FormatContext::default(),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
|
||||||
|
registry::convert_request(
|
||||||
|
"claude:messages",
|
||||||
|
"openai:chat",
|
||||||
|
body_json,
|
||||||
|
&FormatContext::default(),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_gemini_request_to_openai_chat_request(
|
||||||
|
body_json: &Value,
|
||||||
|
request_path: &str,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_request(
|
||||||
|
"gemini:generate_content",
|
||||||
|
"openai:chat",
|
||||||
|
body_json,
|
||||||
|
&FormatContext::default().with_request_path(request_path),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
|
||||||
|
match content {
|
||||||
|
None | Some(Value::Null) => Some(String::new()),
|
||||||
|
Some(Value::String(text)) => Some(text.clone()),
|
||||||
|
Some(Value::Array(parts)) => {
|
||||||
|
let mut collected = Vec::new();
|
||||||
|
for part in parts {
|
||||||
|
let part_object = part.as_object()?;
|
||||||
|
let part_type = part_object
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if matches!(part_type, "text" | "input_text") {
|
||||||
|
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
collected.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(collected.join("\n"))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
|
||||||
|
match content {
|
||||||
|
Some(Value::String(raw)) => {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Value::String(String::new())
|
||||||
|
} else {
|
||||||
|
serde_json::from_str::<Value>(trimmed)
|
||||||
|
.unwrap_or_else(|_| Value::String(raw.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Value::Array(parts)) => {
|
||||||
|
let texts = parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| {
|
||||||
|
part.as_object()
|
||||||
|
.and_then(|object| object.get("text"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if texts.is_empty() {
|
||||||
|
Value::Array(parts.clone())
|
||||||
|
} else {
|
||||||
|
Value::String(texts.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(value) => value.clone(),
|
||||||
|
None => Value::String(String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_context(mapped_model: &str, upstream_is_stream: bool) -> FormatContext {
|
||||||
|
FormatContext::default()
|
||||||
|
.with_mapped_model(mapped_model)
|
||||||
|
.with_upstream_stream(upstream_is_stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
convert_openai_chat_request_to_claude_request,
|
||||||
|
convert_openai_chat_request_to_openai_responses_request,
|
||||||
|
normalize_claude_request_to_openai_chat_request,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_request_facade_routes_through_registry() {
|
||||||
|
let body = json!({
|
||||||
|
"model": "gpt-source",
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = convert_openai_chat_request_to_openai_responses_request(
|
||||||
|
&body,
|
||||||
|
"gpt-target",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("responses request");
|
||||||
|
|
||||||
|
assert_eq!(converted["model"], "gpt-target");
|
||||||
|
assert_eq!(converted["stream"], true);
|
||||||
|
assert_eq!(converted["input"][0]["type"], "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_request_facade_keeps_claude_alias_shape() {
|
||||||
|
let body = json!({
|
||||||
|
"model": "gpt-source",
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted =
|
||||||
|
convert_openai_chat_request_to_claude_request(&body, "claude-target", false)
|
||||||
|
.expect("claude request");
|
||||||
|
|
||||||
|
assert_eq!(converted["model"], "claude-target");
|
||||||
|
assert_eq!(converted["messages"][0]["role"], "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_normalizer_uses_format_adapter() {
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
|
||||||
|
"max_tokens": 128,
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted =
|
||||||
|
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
|
||||||
|
|
||||||
|
assert_eq!(converted["model"], "claude-sonnet");
|
||||||
|
assert_eq!(converted["messages"][0]["role"], "user");
|
||||||
|
assert_eq!(converted["messages"][0]["content"], "hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,573 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::super::to_openai_chat::extract_openai_text_content;
|
|
||||||
use crate::planner::openai::{
|
|
||||||
copy_request_number_field, extract_openai_reasoning_effort, value_as_u64,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn convert_openai_chat_request_to_openai_responses_request(
|
|
||||||
body_json: &Value,
|
|
||||||
mapped_model: &str,
|
|
||||||
upstream_is_stream: bool,
|
|
||||||
compact: bool,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let request = body_json.as_object()?;
|
|
||||||
let mut instructions = Vec::new();
|
|
||||||
let mut input_items = Vec::new();
|
|
||||||
let mut next_generated_tool_call_index = 0usize;
|
|
||||||
let mut tool_call_id_aliases = BTreeMap::new();
|
|
||||||
|
|
||||||
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
|
|
||||||
for message in message_values {
|
|
||||||
let message_object = message.as_object()?;
|
|
||||||
let role = message_object
|
|
||||||
.get("role")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match role.as_str() {
|
|
||||||
"system" | "developer" => {
|
|
||||||
let text = extract_openai_text_content(message_object.get("content"))?;
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
instructions.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"user" | "assistant" => {
|
|
||||||
let mut content_items = convert_openai_content_to_openai_responses_items(
|
|
||||||
message_object.get("content"),
|
|
||||||
role.as_str(),
|
|
||||||
)?;
|
|
||||||
if role == "assistant" {
|
|
||||||
if let Some(refusal) = message_object.get("refusal").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
if !refusal.trim().is_empty() {
|
|
||||||
content_items.push(json!({
|
|
||||||
"type": "refusal",
|
|
||||||
"refusal": refusal,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !content_items.is_empty() {
|
|
||||||
input_items.push(json!({
|
|
||||||
"type": "message",
|
|
||||||
"role": role,
|
|
||||||
"content": content_items,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if role == "assistant" {
|
|
||||||
if let Some(tool_calls) =
|
|
||||||
message_object.get("tool_calls").and_then(Value::as_array)
|
|
||||||
{
|
|
||||||
for tool_call in tool_calls {
|
|
||||||
let tool_call_object = tool_call.as_object()?;
|
|
||||||
let function = tool_call_object.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let raw_call_id = tool_call_object
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let call_id = if raw_call_id.is_empty() {
|
|
||||||
let generated =
|
|
||||||
format!("call_auto_{next_generated_tool_call_index}");
|
|
||||||
next_generated_tool_call_index += 1;
|
|
||||||
generated
|
|
||||||
} else {
|
|
||||||
raw_call_id.to_string()
|
|
||||||
};
|
|
||||||
if !raw_call_id.is_empty() && raw_call_id != call_id {
|
|
||||||
tool_call_id_aliases
|
|
||||||
.insert(raw_call_id.to_string(), call_id.clone());
|
|
||||||
}
|
|
||||||
let arguments = function
|
|
||||||
.get("arguments")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| "{}".to_string());
|
|
||||||
input_items.push(json!({
|
|
||||||
"type": "function_call",
|
|
||||||
"call_id": call_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tool" => {
|
|
||||||
let raw_tool_call_id = message_object
|
|
||||||
.get("tool_call_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let tool_call_id = if raw_tool_call_id.is_empty() {
|
|
||||||
let generated = format!("call_auto_{next_generated_tool_call_index}");
|
|
||||||
next_generated_tool_call_index += 1;
|
|
||||||
generated
|
|
||||||
} else {
|
|
||||||
tool_call_id_aliases
|
|
||||||
.get(raw_tool_call_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| raw_tool_call_id.to_string())
|
|
||||||
};
|
|
||||||
let output = match message_object.get("content") {
|
|
||||||
Some(Value::String(text)) => text.clone(),
|
|
||||||
Some(other) => serde_json::to_string(other).ok()?,
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
input_items.push(json!({
|
|
||||||
"type": "function_call_output",
|
|
||||||
"call_id": tool_call_id,
|
|
||||||
"output": output,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut output = Map::new();
|
|
||||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
|
||||||
if !instructions.is_empty() {
|
|
||||||
output.insert(
|
|
||||||
"instructions".to_string(),
|
|
||||||
Value::String(
|
|
||||||
instructions
|
|
||||||
.into_iter()
|
|
||||||
.filter(|value: &String| !value.trim().is_empty())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n\n"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
output.insert("input".to_string(), Value::Array(input_items));
|
|
||||||
|
|
||||||
if upstream_is_stream && !compact {
|
|
||||||
output.insert("stream".to_string(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
if let Some(max_tokens) = request
|
|
||||||
.get("max_completion_tokens")
|
|
||||||
.and_then(value_as_u64)
|
|
||||||
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
|
|
||||||
{
|
|
||||||
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
|
|
||||||
}
|
|
||||||
copy_request_number_field(request, &mut output, "temperature");
|
|
||||||
copy_request_number_field(request, &mut output, "top_p");
|
|
||||||
copy_request_integer_field(request, &mut output, "top_logprobs");
|
|
||||||
copy_request_bool_field(request, &mut output, "parallel_tool_calls");
|
|
||||||
|
|
||||||
for passthrough_key in [
|
|
||||||
"prompt_cache_key",
|
|
||||||
"prompt_cache_retention",
|
|
||||||
"service_tier",
|
|
||||||
"metadata",
|
|
||||||
"store",
|
|
||||||
"user",
|
|
||||||
"safety_identifier",
|
|
||||||
"previous_response_id",
|
|
||||||
"truncation",
|
|
||||||
"stop",
|
|
||||||
] {
|
|
||||||
if let Some(value) = request.get(passthrough_key) {
|
|
||||||
output.insert(passthrough_key.to_string(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !output.contains_key("reasoning") {
|
|
||||||
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
|
|
||||||
output.insert(
|
|
||||||
"reasoning".to_string(),
|
|
||||||
json!({
|
|
||||||
"effort": if reasoning_effort == "xhigh" {
|
|
||||||
"high"
|
|
||||||
} else {
|
|
||||||
reasoning_effort.as_str()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(text) = build_openai_responses_text_config_from_openai_chat_request(request) {
|
|
||||||
output.insert("text".to_string(), Value::Object(text));
|
|
||||||
}
|
|
||||||
if let Some(tools) = build_openai_responses_tools_from_openai_chat_request(request) {
|
|
||||||
output.insert("tools".to_string(), Value::Array(tools));
|
|
||||||
}
|
|
||||||
if let Some(tool_choice) = build_openai_responses_tool_choice_from_openai_chat_request(request)
|
|
||||||
{
|
|
||||||
output.insert("tool_choice".to_string(), tool_choice);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Value::Object(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_openai_content_to_openai_responses_items(
|
|
||||||
content: Option<&Value>,
|
|
||||||
role: &str,
|
|
||||||
) -> Option<Vec<Value>> {
|
|
||||||
let Some(content) = content else {
|
|
||||||
return Some(Vec::new());
|
|
||||||
};
|
|
||||||
match content {
|
|
||||||
Value::Null => Some(Vec::new()),
|
|
||||||
Value::String(text) => {
|
|
||||||
if text.is_empty() {
|
|
||||||
Some(Vec::new())
|
|
||||||
} else {
|
|
||||||
Some(vec![json!({
|
|
||||||
"type": if role == "assistant" { "output_text" } else { "input_text" },
|
|
||||||
"text": text,
|
|
||||||
})])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::Array(parts) => {
|
|
||||||
let mut items = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part_object = part.as_object()?;
|
|
||||||
let part_type = part_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("text")
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match part_type.as_str() {
|
|
||||||
"text" | "input_text" | "output_text" => {
|
|
||||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
|
||||||
if !text.is_empty() {
|
|
||||||
items.push(json!({
|
|
||||||
"type": if role == "assistant" { "output_text" } else { "input_text" },
|
|
||||||
"text": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"image_url" => {
|
|
||||||
let image_url = part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|value| value.get("url"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| part_object.get("image_url").and_then(Value::as_str))?;
|
|
||||||
let mut item = Map::new();
|
|
||||||
item.insert(
|
|
||||||
"type".to_string(),
|
|
||||||
Value::String(
|
|
||||||
if role == "assistant" {
|
|
||||||
"output_image"
|
|
||||||
} else {
|
|
||||||
"input_image"
|
|
||||||
}
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
item.insert(
|
|
||||||
"image_url".to_string(),
|
|
||||||
Value::String(image_url.to_string()),
|
|
||||||
);
|
|
||||||
if let Some(detail) = part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|value| value.get("detail"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
item.insert("detail".to_string(), detail);
|
|
||||||
}
|
|
||||||
items.push(Value::Object(item));
|
|
||||||
}
|
|
||||||
"input_image" | "output_image" => {
|
|
||||||
let image_url = part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| part_object.get("url").and_then(Value::as_str))?;
|
|
||||||
let mut item = Map::new();
|
|
||||||
item.insert(
|
|
||||||
"type".to_string(),
|
|
||||||
Value::String(
|
|
||||||
if role == "assistant" {
|
|
||||||
"output_image"
|
|
||||||
} else {
|
|
||||||
"input_image"
|
|
||||||
}
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
item.insert(
|
|
||||||
"image_url".to_string(),
|
|
||||||
Value::String(image_url.to_string()),
|
|
||||||
);
|
|
||||||
if let Some(detail) = part_object.get("detail").cloned() {
|
|
||||||
item.insert("detail".to_string(), detail);
|
|
||||||
}
|
|
||||||
items.push(Value::Object(item));
|
|
||||||
}
|
|
||||||
"file" | "input_file" => {
|
|
||||||
let file_object = part_object
|
|
||||||
.get("file")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part_object);
|
|
||||||
let mut item = Map::new();
|
|
||||||
item.insert("type".to_string(), Value::String("input_file".to_string()));
|
|
||||||
if let Some(file_data) = file_object.get("file_data").cloned() {
|
|
||||||
item.insert("file_data".to_string(), file_data);
|
|
||||||
}
|
|
||||||
if let Some(file_id) = file_object.get("file_id").cloned() {
|
|
||||||
item.insert("file_id".to_string(), file_id);
|
|
||||||
}
|
|
||||||
if let Some(filename) = file_object.get("filename").cloned() {
|
|
||||||
item.insert("filename".to_string(), filename);
|
|
||||||
}
|
|
||||||
if item.len() > 1 {
|
|
||||||
items.push(Value::Object(item));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(items)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_responses_text_config_from_openai_chat_request(
|
|
||||||
request: &Map<String, Value>,
|
|
||||||
) -> Option<Map<String, Value>> {
|
|
||||||
let mut text = Map::new();
|
|
||||||
if let Some(response_format) = request.get("response_format") {
|
|
||||||
text.insert("format".to_string(), response_format.clone());
|
|
||||||
}
|
|
||||||
if let Some(verbosity) = request.get("verbosity") {
|
|
||||||
text.insert("verbosity".to_string(), verbosity.clone());
|
|
||||||
}
|
|
||||||
(!text.is_empty()).then_some(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_responses_tools_from_openai_chat_request(
|
|
||||||
request: &Map<String, Value>,
|
|
||||||
) -> Option<Vec<Value>> {
|
|
||||||
let mut tools = Vec::new();
|
|
||||||
if let Some(tool_values) = request.get("tools").and_then(Value::as_array) {
|
|
||||||
for tool in tool_values {
|
|
||||||
let tool_object = tool.as_object()?;
|
|
||||||
let tool_type = tool_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("function")
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match tool_type.as_str() {
|
|
||||||
"function" => {
|
|
||||||
let function = tool_object.get("function")?.as_object()?;
|
|
||||||
let name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let mut rebuilt = Map::new();
|
|
||||||
rebuilt.insert("type".to_string(), Value::String("function".to_string()));
|
|
||||||
rebuilt.insert("name".to_string(), Value::String(name.to_string()));
|
|
||||||
if let Some(description) = function.get("description") {
|
|
||||||
rebuilt.insert("description".to_string(), description.clone());
|
|
||||||
}
|
|
||||||
if let Some(parameters) = function.get("parameters") {
|
|
||||||
rebuilt.insert("parameters".to_string(), parameters.clone());
|
|
||||||
}
|
|
||||||
tools.push(Value::Object(rebuilt));
|
|
||||||
}
|
|
||||||
"custom" => {
|
|
||||||
let custom = tool_object.get("custom").and_then(Value::as_object)?;
|
|
||||||
let mut rebuilt = Map::new();
|
|
||||||
rebuilt.insert("type".to_string(), Value::String("custom".to_string()));
|
|
||||||
if let Some(name) = custom.get("name") {
|
|
||||||
rebuilt.insert("name".to_string(), name.clone());
|
|
||||||
}
|
|
||||||
if let Some(description) = custom.get("description") {
|
|
||||||
rebuilt.insert("description".to_string(), description.clone());
|
|
||||||
}
|
|
||||||
if let Some(format) = custom.get("format") {
|
|
||||||
rebuilt.insert("format".to_string(), format.clone());
|
|
||||||
}
|
|
||||||
tools.push(Value::Object(rebuilt));
|
|
||||||
}
|
|
||||||
_ => tools.push(tool.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(web_search_options) = request.get("web_search_options").and_then(Value::as_object) {
|
|
||||||
let mut tool = Map::new();
|
|
||||||
tool.insert("type".to_string(), Value::String("web_search".to_string()));
|
|
||||||
if let Some(user_location) = web_search_options
|
|
||||||
.get("user_location")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
if user_location.get("type").and_then(Value::as_str) == Some("approximate") {
|
|
||||||
if let Some(approximate) =
|
|
||||||
user_location.get("approximate").and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let mut flattened = Map::new();
|
|
||||||
flattened.insert("type".to_string(), Value::String("approximate".to_string()));
|
|
||||||
if let Some(country) = approximate.get("country") {
|
|
||||||
flattened.insert("country".to_string(), country.clone());
|
|
||||||
}
|
|
||||||
if let Some(city) = approximate.get("city") {
|
|
||||||
flattened.insert("city".to_string(), city.clone());
|
|
||||||
}
|
|
||||||
tool.insert("user_location".to_string(), Value::Object(flattened));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(search_context_size) = web_search_options.get("search_context_size") {
|
|
||||||
tool.insert(
|
|
||||||
"search_context_size".to_string(),
|
|
||||||
search_context_size.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tools.push(Value::Object(tool));
|
|
||||||
}
|
|
||||||
|
|
||||||
(!tools.is_empty()).then_some(tools)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_responses_tool_choice_from_openai_chat_request(
|
|
||||||
request: &Map<String, Value>,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let tool_choice = request.get("tool_choice")?;
|
|
||||||
match tool_choice {
|
|
||||||
Value::String(value) => Some(Value::String(value.clone())),
|
|
||||||
Value::Object(object) => {
|
|
||||||
let choice_type = object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match choice_type.as_str() {
|
|
||||||
"function" => {
|
|
||||||
let function = object.get("function").and_then(Value::as_object)?;
|
|
||||||
let name = function.get("name")?.as_str()?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "function",
|
|
||||||
"name": name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"custom" => {
|
|
||||||
let custom = object.get("custom").and_then(Value::as_object)?;
|
|
||||||
let name = custom.get("name")?.as_str()?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "custom",
|
|
||||||
"name": name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"allowed_tools" => {
|
|
||||||
let allowed_tools = object.get("allowed_tools").and_then(Value::as_object)?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "allowed_tools",
|
|
||||||
"mode": allowed_tools.get("mode").cloned().unwrap_or_else(|| Value::String("auto".to_string())),
|
|
||||||
"tools": allowed_tools.get("tools").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
_ => Some(tool_choice.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Some(tool_choice.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_request_integer_field(
|
|
||||||
request: &Map<String, Value>,
|
|
||||||
output: &mut Map<String, Value>,
|
|
||||||
field: &str,
|
|
||||||
) {
|
|
||||||
if let Some(value) = request.get(field).and_then(Value::as_i64) {
|
|
||||||
output.insert(field.to_string(), Value::from(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_request_bool_field(
|
|
||||||
request: &Map<String, Value>,
|
|
||||||
output: &mut Map<String, Value>,
|
|
||||||
field: &str,
|
|
||||||
) {
|
|
||||||
if let Some(value) = request.get(field).and_then(Value::as_bool) {
|
|
||||||
output.insert(field.to_string(), Value::Bool(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_openai_chat_request_to_openai_responses_request;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_shared_openai_chat_controls_when_converting_to_openai_responses() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"messages": [{"role": "user", "content": "hi"}],
|
|
||||||
"max_completion_tokens": 256,
|
|
||||||
"verbosity": "low",
|
|
||||||
"prompt_cache_key": "cache-key-123",
|
|
||||||
"prompt_cache_retention": "persist",
|
|
||||||
"service_tier": "priority",
|
|
||||||
"user": "user-123",
|
|
||||||
"safety_identifier": "safe-123",
|
|
||||||
"top_logprobs": 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
|
||||||
&request,
|
|
||||||
"gpt-5-upstream",
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("chat request should convert to responses");
|
|
||||||
|
|
||||||
assert_eq!(converted["model"], "gpt-5-upstream");
|
|
||||||
assert_eq!(converted["max_output_tokens"], 256);
|
|
||||||
assert_eq!(converted["prompt_cache_key"], "cache-key-123");
|
|
||||||
assert_eq!(converted["prompt_cache_retention"], "persist");
|
|
||||||
assert_eq!(converted["service_tier"], "priority");
|
|
||||||
assert_eq!(converted["user"], "user-123");
|
|
||||||
assert_eq!(converted["safety_identifier"], "safe-123");
|
|
||||||
assert_eq!(converted["top_logprobs"], 3);
|
|
||||||
assert_eq!(converted["text"]["verbosity"], "low");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_assistant_refusal_when_converting_to_openai_responses() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"messages": [{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"refusal": "cannot comply"
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
|
||||||
&request,
|
|
||||||
"gpt-5-upstream",
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("chat request should convert to responses");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["input"][0]["content"],
|
|
||||||
json!([{
|
|
||||||
"type": "refusal",
|
|
||||||
"refusal": "cannot comply"
|
|
||||||
}])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
mod from_chat;
|
|
||||||
mod to_chat;
|
|
||||||
|
|
||||||
pub use from_chat::convert_openai_chat_request_to_openai_responses_request;
|
|
||||||
pub use to_chat::normalize_openai_responses_request_to_openai_chat_request;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
convert_openai_chat_request_to_openai_responses_request,
|
|
||||||
normalize_openai_responses_request_to_openai_chat_request,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_chat_to_responses_wire_shape() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"messages": [{"role": "user", "content": "hello"}],
|
|
||||||
"max_completion_tokens": 16
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
|
||||||
&request,
|
|
||||||
"gpt-5-mini",
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("responses request");
|
|
||||||
|
|
||||||
assert_eq!(converted["model"], "gpt-5-mini");
|
|
||||||
assert_eq!(converted["input"][0]["type"], "message");
|
|
||||||
assert_eq!(converted["max_output_tokens"], 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_responses_wire_shape_to_chat() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"input": [{
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"type": "input_text", "text": "hello"}]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
|
||||||
.expect("chat request");
|
|
||||||
|
|
||||||
assert_eq!(converted["messages"][0]["role"], "user");
|
|
||||||
assert_eq!(converted["messages"][0]["content"][0]["type"], "text");
|
|
||||||
assert_eq!(converted["messages"][0]["content"][0]["text"], "hello");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
|
|
||||||
use crate::planner::openai::extract_openai_reasoning_effort;
|
|
||||||
|
|
||||||
pub fn normalize_openai_responses_request_to_openai_chat_request(
|
|
||||||
body_json: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let request = body_json.as_object()?;
|
|
||||||
let mut output = Map::new();
|
|
||||||
if let Some(model) = request.get("model") {
|
|
||||||
output.insert("model".to_string(), model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
if let Some(instructions) = request.get("instructions") {
|
|
||||||
let text = extract_openai_text_content(Some(instructions))?;
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "system",
|
|
||||||
"content": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages.extend(normalize_openai_responses_input_to_openai_chat_messages(
|
|
||||||
request.get("input"),
|
|
||||||
)?);
|
|
||||||
output.insert("messages".to_string(), Value::Array(messages));
|
|
||||||
|
|
||||||
if let Some(max_output_tokens) = request.get("max_output_tokens").cloned() {
|
|
||||||
output.insert("max_completion_tokens".to_string(), max_output_tokens);
|
|
||||||
}
|
|
||||||
for passthrough_key in [
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"metadata",
|
|
||||||
"store",
|
|
||||||
"service_tier",
|
|
||||||
"prompt_cache_key",
|
|
||||||
"prompt_cache_retention",
|
|
||||||
"parallel_tool_calls",
|
|
||||||
"stop",
|
|
||||||
"stream",
|
|
||||||
"stream_options",
|
|
||||||
"user",
|
|
||||||
"safety_identifier",
|
|
||||||
"top_logprobs",
|
|
||||||
] {
|
|
||||||
if let Some(value) = request.get(passthrough_key) {
|
|
||||||
output.insert(passthrough_key.to_string(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
|
|
||||||
output.insert(
|
|
||||||
"reasoning_effort".to_string(),
|
|
||||||
Value::String(reasoning_effort),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(response_format) = request
|
|
||||||
.get("text")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|text| text.get("format"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
output.insert("response_format".to_string(), response_format);
|
|
||||||
}
|
|
||||||
if let Some(verbosity) = request
|
|
||||||
.get("text")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|text| text.get("verbosity"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
output.insert("verbosity".to_string(), verbosity);
|
|
||||||
}
|
|
||||||
if let Some(tools) = normalize_openai_responses_tools_to_openai_chat(request.get("tools"))? {
|
|
||||||
output.insert("tools".to_string(), Value::Array(tools));
|
|
||||||
}
|
|
||||||
if let Some(web_search_options) =
|
|
||||||
extract_openai_responses_web_search_options(request.get("tools").and_then(Value::as_array))
|
|
||||||
{
|
|
||||||
output.insert("web_search_options".to_string(), web_search_options);
|
|
||||||
}
|
|
||||||
if let Some(tool_choice) =
|
|
||||||
normalize_openai_responses_tool_choice_to_openai_chat(request.get("tool_choice"))?
|
|
||||||
{
|
|
||||||
output.insert("tool_choice".to_string(), tool_choice);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Value::Object(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_openai_responses_input_to_openai_chat_messages(
|
|
||||||
input: Option<&Value>,
|
|
||||||
) -> Option<Vec<Value>> {
|
|
||||||
let Some(input) = input else {
|
|
||||||
return Some(Vec::new());
|
|
||||||
};
|
|
||||||
match input {
|
|
||||||
Value::Null => Some(Vec::new()),
|
|
||||||
Value::String(text) => {
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
Some(Vec::new())
|
|
||||||
} else {
|
|
||||||
Some(vec![json!({
|
|
||||||
"role": "user",
|
|
||||||
"content": text,
|
|
||||||
})])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::Array(items) => {
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
let mut next_generated_tool_call_index = 0usize;
|
|
||||||
for item in items {
|
|
||||||
if let Some(item_text) = item.as_str() {
|
|
||||||
if !item_text.trim().is_empty() {
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "user",
|
|
||||||
"content": item_text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let item_object = item.as_object()?;
|
|
||||||
let item_type = item_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("message")
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match item_type.as_str() {
|
|
||||||
"message" => {
|
|
||||||
let role = item_object
|
|
||||||
.get("role")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("user")
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if role == "system" || role == "developer" {
|
|
||||||
let text = extract_openai_text_content(item_object.get("content"))?;
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "system",
|
|
||||||
"content": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let normalized_content =
|
|
||||||
normalize_openai_responses_message_content(item_object.get("content"))?;
|
|
||||||
let mut message = serde_json::Map::new();
|
|
||||||
message.insert("role".to_string(), Value::String(role.clone()));
|
|
||||||
message.insert("content".to_string(), normalized_content);
|
|
||||||
if role == "assistant" {
|
|
||||||
if let Some(refusal) = extract_openai_responses_message_refusal(
|
|
||||||
item_object.get("content"),
|
|
||||||
)? {
|
|
||||||
message.insert("refusal".to_string(), Value::String(refusal));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages.push(Value::Object(message));
|
|
||||||
}
|
|
||||||
"function_call" => {
|
|
||||||
let tool_name = item_object
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let call_id = item_object
|
|
||||||
.get("call_id")
|
|
||||||
.or_else(|| item_object.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let generated =
|
|
||||||
format!("call_auto_{next_generated_tool_call_index}");
|
|
||||||
next_generated_tool_call_index += 1;
|
|
||||||
generated
|
|
||||||
});
|
|
||||||
let arguments = item_object
|
|
||||||
.get("arguments")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| "{}".to_string());
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": Value::Array(Vec::new()),
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": call_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"function_call_output" => {
|
|
||||||
let tool_call_id = item_object
|
|
||||||
.get("call_id")
|
|
||||||
.or_else(|| item_object.get("tool_call_id"))
|
|
||||||
.or_else(|| item_object.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let generated =
|
|
||||||
format!("call_auto_{next_generated_tool_call_index}");
|
|
||||||
next_generated_tool_call_index += 1;
|
|
||||||
generated
|
|
||||||
});
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_call_id,
|
|
||||||
"content": parse_openai_tool_result_content(item_object.get("output")),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(messages)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_openai_responses_message_content(content: Option<&Value>) -> Option<Value> {
|
|
||||||
let Some(content) = content else {
|
|
||||||
return Some(Value::Array(Vec::new()));
|
|
||||||
};
|
|
||||||
match content {
|
|
||||||
Value::String(text) => Some(Value::String(text.clone())),
|
|
||||||
Value::Array(parts) => {
|
|
||||||
let mut normalized = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part_object = part.as_object()?;
|
|
||||||
let part_type = part_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match part_type.as_str() {
|
|
||||||
"input_text" | "output_text" | "text" => {
|
|
||||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
|
||||||
normalized.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"input_image" | "output_image" | "image_url" => {
|
|
||||||
let image_url = part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(|value| {
|
|
||||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
|
||||||
value
|
|
||||||
.as_object()
|
|
||||||
.and_then(|object| object.get("url"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
part_object
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})?;
|
|
||||||
let detail = part_object
|
|
||||||
.get("detail")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| {
|
|
||||||
part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|image| image.get("detail"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
});
|
|
||||||
let mut image = Map::new();
|
|
||||||
image.insert("url".to_string(), Value::String(image_url));
|
|
||||||
if let Some(detail) = detail {
|
|
||||||
image.insert("detail".to_string(), Value::String(detail));
|
|
||||||
}
|
|
||||||
normalized.push(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": image,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"input_file" => {
|
|
||||||
let mut file = Map::new();
|
|
||||||
if let Some(file_data) = part_object.get("file_data").cloned() {
|
|
||||||
file.insert("file_data".to_string(), file_data);
|
|
||||||
}
|
|
||||||
if let Some(file_id) = part_object.get("file_id").cloned() {
|
|
||||||
file.insert("file_id".to_string(), file_id);
|
|
||||||
}
|
|
||||||
if let Some(filename) = part_object.get("filename").cloned() {
|
|
||||||
file.insert("filename".to_string(), filename);
|
|
||||||
}
|
|
||||||
if !file.is_empty() {
|
|
||||||
normalized.push(json!({
|
|
||||||
"type": "file",
|
|
||||||
"file": file,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Array(normalized))
|
|
||||||
}
|
|
||||||
_ => Some(content.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_responses_message_refusal(content: Option<&Value>) -> Option<Option<String>> {
|
|
||||||
let Some(content) = content else {
|
|
||||||
return Some(None);
|
|
||||||
};
|
|
||||||
match content {
|
|
||||||
Value::Array(parts) => {
|
|
||||||
let mut refusals = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part_object = part.as_object()?;
|
|
||||||
let part_type = part_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if part_type == "refusal" {
|
|
||||||
if let Some(refusal) = part_object.get("refusal").and_then(Value::as_str) {
|
|
||||||
if !refusal.trim().is_empty() {
|
|
||||||
refusals.push(refusal.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if refusals.is_empty() {
|
|
||||||
Some(None)
|
|
||||||
} else {
|
|
||||||
Some(Some(refusals.join("\n")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Some(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_openai_responses_tools_to_openai_chat(
|
|
||||||
tools: Option<&Value>,
|
|
||||||
) -> Option<Option<Vec<Value>>> {
|
|
||||||
let Some(Value::Array(tool_values)) = tools else {
|
|
||||||
return Some(None);
|
|
||||||
};
|
|
||||||
let mut normalized = Vec::new();
|
|
||||||
for tool in tool_values {
|
|
||||||
let tool_object = tool.as_object()?;
|
|
||||||
let tool_type = tool_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("function")
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if tool_type.starts_with("web_search") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if tool_object.get("function").is_some() || tool_type != "function" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = tool_object
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let mut function = Map::new();
|
|
||||||
function.insert("name".to_string(), Value::String(name.to_string()));
|
|
||||||
if let Some(description) = tool_object.get("description") {
|
|
||||||
function.insert("description".to_string(), description.clone());
|
|
||||||
}
|
|
||||||
if let Some(parameters) = tool_object.get("parameters") {
|
|
||||||
function.insert("parameters".to_string(), parameters.clone());
|
|
||||||
}
|
|
||||||
normalized.push(json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": function,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Some((!normalized.is_empty()).then_some(normalized))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_responses_web_search_options(tools: Option<&Vec<Value>>) -> Option<Value> {
|
|
||||||
let tool_values = tools?;
|
|
||||||
for tool in tool_values {
|
|
||||||
let tool_object = tool.as_object()?;
|
|
||||||
let tool_type = tool_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !tool_type.starts_with("web_search") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut options = Map::new();
|
|
||||||
if let Some(search_context_size) = tool_object.get("search_context_size").cloned() {
|
|
||||||
options.insert("search_context_size".to_string(), search_context_size);
|
|
||||||
}
|
|
||||||
if let Some(user_location) = tool_object.get("user_location").and_then(Value::as_object) {
|
|
||||||
let mut approximate = Map::new();
|
|
||||||
for field in ["city", "country", "region", "timezone"] {
|
|
||||||
if let Some(value) = user_location.get(field).cloned() {
|
|
||||||
approximate.insert(field.to_string(), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !approximate.is_empty() {
|
|
||||||
options.insert(
|
|
||||||
"user_location".to_string(),
|
|
||||||
json!({
|
|
||||||
"type": "approximate",
|
|
||||||
"approximate": approximate,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !options.is_empty() {
|
|
||||||
return Some(Value::Object(options));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_openai_responses_tool_choice_to_openai_chat(
|
|
||||||
tool_choice: Option<&Value>,
|
|
||||||
) -> Option<Option<Value>> {
|
|
||||||
let Some(tool_choice) = tool_choice else {
|
|
||||||
return Some(None);
|
|
||||||
};
|
|
||||||
match tool_choice {
|
|
||||||
Value::Object(object)
|
|
||||||
if object.get("function").is_none()
|
|
||||||
&& object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case("function")) =>
|
|
||||||
{
|
|
||||||
let name = object
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
Some(Some(json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": name,
|
|
||||||
}
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
_ => Some(Some(tool_choice.clone())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::normalize_openai_responses_request_to_openai_chat_request;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_openai_responses_text_and_passthrough_fields_when_normalizing_to_chat() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"max_output_tokens": 128,
|
|
||||||
"input": [{
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"type": "input_text", "text": "hi"}]
|
|
||||||
}],
|
|
||||||
"text": {
|
|
||||||
"format": {
|
|
||||||
"type": "json_schema",
|
|
||||||
"json_schema": {"name": "answer", "schema": {"type": "object"}}
|
|
||||||
},
|
|
||||||
"verbosity": "high"
|
|
||||||
},
|
|
||||||
"prompt_cache_key": "cache-key-456",
|
|
||||||
"prompt_cache_retention": "persist",
|
|
||||||
"service_tier": "flex",
|
|
||||||
"user": "user-456",
|
|
||||||
"safety_identifier": "safe-456",
|
|
||||||
"top_logprobs": 4
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
|
||||||
.expect("responses request should normalize to chat");
|
|
||||||
|
|
||||||
assert_eq!(converted["max_completion_tokens"], 128);
|
|
||||||
assert_eq!(
|
|
||||||
converted["response_format"],
|
|
||||||
json!({
|
|
||||||
"type": "json_schema",
|
|
||||||
"json_schema": {"name": "answer", "schema": {"type": "object"}}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(converted["verbosity"], "high");
|
|
||||||
assert_eq!(converted["prompt_cache_key"], "cache-key-456");
|
|
||||||
assert_eq!(converted["prompt_cache_retention"], "persist");
|
|
||||||
assert_eq!(converted["service_tier"], "flex");
|
|
||||||
assert_eq!(converted["user"], "user-456");
|
|
||||||
assert_eq!(converted["safety_identifier"], "safe-456");
|
|
||||||
assert_eq!(converted["top_logprobs"], 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_assistant_refusal_when_normalizing_to_chat() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"input": [{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "refusal", "refusal": "cannot comply"}]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
|
||||||
.expect("responses request should normalize to chat");
|
|
||||||
|
|
||||||
assert_eq!(converted["messages"][0]["role"], "assistant");
|
|
||||||
assert_eq!(converted["messages"][0]["refusal"], "cannot comply");
|
|
||||||
assert_eq!(converted["messages"][0]["content"], json!([]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn passes_through_stream_options() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"stream": true,
|
|
||||||
"stream_options": {
|
|
||||||
"include_usage": true
|
|
||||||
},
|
|
||||||
"input": "hello"
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
|
||||||
.expect("responses request should normalize to chat");
|
|
||||||
|
|
||||||
assert_eq!(converted["stream"], true);
|
|
||||||
assert_eq!(converted["stream_options"]["include_usage"], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_stream_options_without_forcing_include_usage_during_normalization() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gpt-5",
|
|
||||||
"stream": true,
|
|
||||||
"stream_options": {
|
|
||||||
"include_usage": false,
|
|
||||||
"extra": "keep-me"
|
|
||||||
},
|
|
||||||
"input": "hello"
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&request)
|
|
||||||
.expect("responses request should normalize to chat");
|
|
||||||
|
|
||||||
assert_eq!(converted["stream_options"]["include_usage"], false);
|
|
||||||
assert_eq!(converted["stream_options"]["extra"], "keep-me");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,906 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::shared::canonical_json_string;
|
|
||||||
use crate::planner::openai::map_thinking_budget_to_openai_reasoning_effort;
|
|
||||||
|
|
||||||
pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
|
|
||||||
let request = body_json.as_object()?;
|
|
||||||
let mut output = Map::new();
|
|
||||||
let mut next_generated_tool_use_index = 0usize;
|
|
||||||
if let Some(model) = request.get("model") {
|
|
||||||
output.insert("model".to_string(), model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
if let Some(system_text) = extract_claude_system_text(request.get("system")) {
|
|
||||||
if !system_text.trim().is_empty() {
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "system",
|
|
||||||
"content": system_text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
|
|
||||||
for message in message_values {
|
|
||||||
let message_object = message.as_object()?;
|
|
||||||
let role = message_object
|
|
||||||
.get("role")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match role.as_str() {
|
|
||||||
"user" => {
|
|
||||||
append_claude_user_message_to_openai_messages(
|
|
||||||
message_object.get("content"),
|
|
||||||
&mut messages,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
"assistant" => {
|
|
||||||
messages.push(normalize_claude_assistant_message_to_openai_message(
|
|
||||||
message_object.get("content"),
|
|
||||||
&mut next_generated_tool_use_index,
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.insert("messages".to_string(), Value::Array(messages));
|
|
||||||
|
|
||||||
if let Some(max_tokens) = request.get("max_tokens").cloned() {
|
|
||||||
output.insert("max_completion_tokens".to_string(), max_tokens);
|
|
||||||
}
|
|
||||||
for passthrough_key in ["temperature", "top_p", "metadata", "stop", "stream"] {
|
|
||||||
if let Some(value) = request.get(passthrough_key) {
|
|
||||||
output.insert(passthrough_key.to_string(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if output.get("stop").is_none() {
|
|
||||||
if let Some(stop_sequences) = request
|
|
||||||
.get("stop_sequences")
|
|
||||||
.cloned()
|
|
||||||
.filter(|value| !value.is_null())
|
|
||||||
{
|
|
||||||
output.insert("stop".to_string(), stop_sequences);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if output.get("reasoning_effort").is_none() {
|
|
||||||
if let Some(reasoning_effort) = extract_claude_output_reasoning_effort(request) {
|
|
||||||
output.insert(
|
|
||||||
"reasoning_effort".to_string(),
|
|
||||||
Value::String(reasoning_effort.to_string()),
|
|
||||||
);
|
|
||||||
} else if let Some(thinking_budget) = request
|
|
||||||
.get("thinking")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|thinking| thinking.get("budget_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
{
|
|
||||||
output.insert(
|
|
||||||
"reasoning_effort".to_string(),
|
|
||||||
Value::String(
|
|
||||||
map_thinking_budget_to_openai_reasoning_effort(thinking_budget).to_string(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(tools) = normalize_claude_tools_to_openai(request.get("tools"))? {
|
|
||||||
output.insert("tools".to_string(), Value::Array(tools));
|
|
||||||
}
|
|
||||||
if let Some(web_search_options) = extract_claude_web_search_options(request.get("tools")) {
|
|
||||||
output.insert("web_search_options".to_string(), web_search_options);
|
|
||||||
}
|
|
||||||
if let Some(tool_choice) = normalize_claude_tool_choice_to_openai(request.get("tool_choice"))? {
|
|
||||||
output.insert("tool_choice".to_string(), tool_choice);
|
|
||||||
}
|
|
||||||
if let Some(parallel_tool_calls) =
|
|
||||||
extract_claude_parallel_tool_calls(request.get("tool_choice"))
|
|
||||||
{
|
|
||||||
output.insert(
|
|
||||||
"parallel_tool_calls".to_string(),
|
|
||||||
Value::Bool(parallel_tool_calls),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Value::Object(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum ClaudeNormalizedBlock {
|
|
||||||
Text(String),
|
|
||||||
Thinking {
|
|
||||||
text: String,
|
|
||||||
signature: Option<String>,
|
|
||||||
},
|
|
||||||
RedactedThinking {
|
|
||||||
data: String,
|
|
||||||
},
|
|
||||||
ImageUrl(String),
|
|
||||||
FileData(String),
|
|
||||||
FileUrl(String),
|
|
||||||
ToolUse {
|
|
||||||
id: Option<String>,
|
|
||||||
name: String,
|
|
||||||
input: Option<Value>,
|
|
||||||
},
|
|
||||||
ToolResult {
|
|
||||||
tool_use_id: String,
|
|
||||||
content: Value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_claude_content_blocks(content: &Value) -> Option<Vec<ClaudeNormalizedBlock>> {
|
|
||||||
match content {
|
|
||||||
Value::String(text) => Some(vec![ClaudeNormalizedBlock::Text(text.clone())]),
|
|
||||||
Value::Array(blocks) => {
|
|
||||||
let mut normalized = Vec::new();
|
|
||||||
for block in blocks {
|
|
||||||
let block = block.as_object()?;
|
|
||||||
match block.get("type")?.as_str()? {
|
|
||||||
"text" => {
|
|
||||||
let text = block
|
|
||||||
.get("text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
normalized.push(ClaudeNormalizedBlock::Text(text.to_string()));
|
|
||||||
}
|
|
||||||
"thinking" => {
|
|
||||||
let thinking = block
|
|
||||||
.get("thinking")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| block.get("text").and_then(Value::as_str))
|
|
||||||
.unwrap_or_default();
|
|
||||||
normalized.push(ClaudeNormalizedBlock::Thinking {
|
|
||||||
text: thinking.to_string(),
|
|
||||||
signature: block
|
|
||||||
.get("signature")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"redacted_thinking" => {
|
|
||||||
let data = block
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
normalized.push(ClaudeNormalizedBlock::RedactedThinking {
|
|
||||||
data: data.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"image" => {
|
|
||||||
let source = block.get("source")?.as_object()?;
|
|
||||||
match source.get("type")?.as_str()? {
|
|
||||||
"base64" => {
|
|
||||||
let media_type = source
|
|
||||||
.get("media_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let data = source
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
normalized.push(ClaudeNormalizedBlock::ImageUrl(build_data_url(
|
|
||||||
media_type, data,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
"url" => {
|
|
||||||
let url = source
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_string();
|
|
||||||
normalized.push(ClaudeNormalizedBlock::ImageUrl(url));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"document" => {
|
|
||||||
let source = block.get("source")?.as_object()?;
|
|
||||||
match source.get("type")?.as_str()? {
|
|
||||||
"base64" => {
|
|
||||||
let media_type = source
|
|
||||||
.get("media_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let data = source
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
normalized.push(ClaudeNormalizedBlock::FileData(build_data_url(
|
|
||||||
media_type, data,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
"url" => {
|
|
||||||
let url = source
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_string();
|
|
||||||
normalized.push(ClaudeNormalizedBlock::FileUrl(url));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tool_use" => {
|
|
||||||
let name = block
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_string();
|
|
||||||
normalized.push(ClaudeNormalizedBlock::ToolUse {
|
|
||||||
id: block
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
name,
|
|
||||||
input: block.get("input").cloned(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"tool_result" => {
|
|
||||||
let tool_use_id = block
|
|
||||||
.get("tool_use_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_string();
|
|
||||||
let content = block.get("content").cloned().unwrap_or(Value::Null);
|
|
||||||
normalized.push(ClaudeNormalizedBlock::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(normalized)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_claude_user_message_to_openai_messages(
|
|
||||||
content: Option<&Value>,
|
|
||||||
messages: &mut Vec<Value>,
|
|
||||||
) -> Option<()> {
|
|
||||||
let Some(content) = content else {
|
|
||||||
return Some(());
|
|
||||||
};
|
|
||||||
let mut pending_parts = Vec::new();
|
|
||||||
for block in normalize_claude_content_blocks(content)? {
|
|
||||||
match block {
|
|
||||||
ClaudeNormalizedBlock::Text(text) | ClaudeNormalizedBlock::Thinking { text, .. } => {
|
|
||||||
push_openai_text_part(&mut pending_parts, text);
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::RedactedThinking { .. } => {}
|
|
||||||
ClaudeNormalizedBlock::ImageUrl(url) => {
|
|
||||||
pending_parts.push(build_openai_image_part(url));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::FileData(file_data) => {
|
|
||||||
pending_parts.push(build_openai_file_part(file_data));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::FileUrl(url) => {
|
|
||||||
push_openai_text_part(&mut pending_parts, format!("[File: {url}]"));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
} => {
|
|
||||||
flush_openai_user_content_parts(&mut pending_parts, messages);
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_use_id,
|
|
||||||
"content": content,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::ToolUse { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush_openai_user_content_parts(&mut pending_parts, messages);
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_claude_assistant_message_to_openai_message(
|
|
||||||
content: Option<&Value>,
|
|
||||||
next_generated_tool_use_index: &mut usize,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let mut reasoning_segments = Vec::new();
|
|
||||||
let mut reasoning_parts = Vec::new();
|
|
||||||
let mut content_parts = Vec::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
if let Some(content) = content {
|
|
||||||
for block in normalize_claude_content_blocks(content)? {
|
|
||||||
match block {
|
|
||||||
ClaudeNormalizedBlock::Text(text) => {
|
|
||||||
push_openai_text_part(&mut content_parts, text);
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::Thinking { text, signature } => {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
reasoning_segments.push(text.clone());
|
|
||||||
}
|
|
||||||
let mut reasoning_part = Map::new();
|
|
||||||
reasoning_part
|
|
||||||
.insert("type".to_string(), Value::String("thinking".to_string()));
|
|
||||||
reasoning_part.insert("thinking".to_string(), Value::String(text));
|
|
||||||
if let Some(signature) = signature {
|
|
||||||
reasoning_part.insert("signature".to_string(), Value::String(signature));
|
|
||||||
}
|
|
||||||
reasoning_parts.push(Value::Object(reasoning_part));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::RedactedThinking { data } => {
|
|
||||||
if data.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
reasoning_parts.push(json!({
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::ImageUrl(url) => {
|
|
||||||
content_parts.push(build_openai_image_part(url));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::FileData(file_data) => {
|
|
||||||
content_parts.push(build_openai_file_part(file_data));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::FileUrl(url) => {
|
|
||||||
push_openai_text_part(&mut content_parts, format!("[File: {url}]"));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::ToolUse { id, name, input } => {
|
|
||||||
let tool_use_id = id.unwrap_or_else(|| {
|
|
||||||
let generated = format!("toolu_auto_{next_generated_tool_use_index}");
|
|
||||||
*next_generated_tool_use_index += 1;
|
|
||||||
generated
|
|
||||||
});
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_use_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": name,
|
|
||||||
"arguments": canonical_json_string(input.unwrap_or(Value::Object(Map::new()))),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
ClaudeNormalizedBlock::ToolResult { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut assistant = Map::new();
|
|
||||||
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
assistant.insert(
|
|
||||||
"content".to_string(),
|
|
||||||
match build_openai_content_value(content_parts) {
|
|
||||||
Some(content) => content,
|
|
||||||
None if !tool_calls.is_empty() => Value::Null,
|
|
||||||
None => Value::String(String::new()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if !reasoning_segments.is_empty() {
|
|
||||||
assistant.insert(
|
|
||||||
"reasoning_content".to_string(),
|
|
||||||
Value::String(reasoning_segments.join("")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !reasoning_parts.is_empty() {
|
|
||||||
assistant.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
|
|
||||||
}
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
Some(Value::Object(assistant))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush_openai_user_content_parts(pending_parts: &mut Vec<Value>, messages: &mut Vec<Value>) {
|
|
||||||
let parts = std::mem::take(pending_parts);
|
|
||||||
let Some(content) = build_openai_content_value(parts) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "user",
|
|
||||||
"content": content,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_content_value(parts: Vec<Value>) -> Option<Value> {
|
|
||||||
if parts.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if parts
|
|
||||||
.iter()
|
|
||||||
.all(|part| part.get("type").and_then(Value::as_str) == Some("text"))
|
|
||||||
{
|
|
||||||
let text = parts
|
|
||||||
.iter()
|
|
||||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n\n");
|
|
||||||
return Some(Value::String(text));
|
|
||||||
}
|
|
||||||
Some(Value::Array(parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_openai_text_part(parts: &mut Vec<Value>, text: String) {
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_image_part(url: String) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": url,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_file_part(file_data: String) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": file_data,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_data_url(media_type: &str, data: &str) -> String {
|
|
||||||
format!("data:{media_type};base64,{data}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_claude_output_reasoning_effort(request: &Map<String, Value>) -> Option<&'static str> {
|
|
||||||
match request
|
|
||||||
.get("output_config")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|config| config.get("effort"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.as_str()
|
|
||||||
{
|
|
||||||
"low" => Some("low"),
|
|
||||||
"medium" => Some("medium"),
|
|
||||||
"high" => Some("high"),
|
|
||||||
"max" | "xhigh" => Some("xhigh"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_claude_system_text(system: Option<&Value>) -> Option<String> {
|
|
||||||
let system = system?;
|
|
||||||
let text = match system {
|
|
||||||
Value::String(text) => text.clone(),
|
|
||||||
Value::Array(blocks) => {
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
for block in blocks {
|
|
||||||
let block = block.as_object()?;
|
|
||||||
if block.get("type").and_then(Value::as_str).unwrap_or("text") == "text" {
|
|
||||||
let text = block
|
|
||||||
.get("text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
segments.push(text.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
segments.join("\n\n")
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(strip_claude_billing_header(&text))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn strip_claude_billing_header(text: &str) -> String {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
let prefix = "x-anthropic-billing-header:";
|
|
||||||
if !trimmed.to_ascii_lowercase().starts_with(prefix) {
|
|
||||||
return trimmed.to_string();
|
|
||||||
}
|
|
||||||
let remainder = trimmed
|
|
||||||
.split_once('\n')
|
|
||||||
.map(|(_, rest)| rest.trim_start())
|
|
||||||
.unwrap_or_default();
|
|
||||||
remainder.trim_start_matches('\n').trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_claude_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
|
|
||||||
let Some(tools) = tools else {
|
|
||||||
return Some(None);
|
|
||||||
};
|
|
||||||
let tools = tools.as_array()?;
|
|
||||||
let mut normalized = Vec::new();
|
|
||||||
for tool in tools {
|
|
||||||
let tool = tool.as_object()?;
|
|
||||||
if tool
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value.starts_with("web_search"))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = tool
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let mut function = Map::new();
|
|
||||||
function.insert("name".to_string(), Value::String(name.to_string()));
|
|
||||||
if let Some(description) = tool.get("description").and_then(Value::as_str) {
|
|
||||||
if !description.trim().is_empty() {
|
|
||||||
function.insert(
|
|
||||||
"description".to_string(),
|
|
||||||
Value::String(description.trim().to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function.insert(
|
|
||||||
"parameters".to_string(),
|
|
||||||
tool.get("input_schema")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| json!({"type": "object"})),
|
|
||||||
);
|
|
||||||
normalized.push(json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": Value::Object(function),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if normalized.is_empty() {
|
|
||||||
Some(None)
|
|
||||||
} else {
|
|
||||||
Some(Some(normalized))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_claude_web_search_options(tools: Option<&Value>) -> Option<Value> {
|
|
||||||
let tools = tools?.as_array()?;
|
|
||||||
for tool in tools {
|
|
||||||
let tool = tool.as_object()?;
|
|
||||||
let tool_type = tool
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if !tool_type.starts_with("web_search") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let found = true;
|
|
||||||
let mut options = Map::new();
|
|
||||||
if let Some(max_uses) = tool.get("max_uses").and_then(Value::as_u64) {
|
|
||||||
let search_context_size = if max_uses <= 1 {
|
|
||||||
"low"
|
|
||||||
} else if max_uses <= 5 {
|
|
||||||
"medium"
|
|
||||||
} else {
|
|
||||||
"high"
|
|
||||||
};
|
|
||||||
options.insert(
|
|
||||||
"search_context_size".to_string(),
|
|
||||||
Value::String(search_context_size.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(user_location) = tool.get("user_location").and_then(Value::as_object) {
|
|
||||||
let mut approximate = Map::new();
|
|
||||||
for field in ["city", "country", "region", "timezone"] {
|
|
||||||
if let Some(value) = user_location.get(field).cloned() {
|
|
||||||
approximate.insert(field.to_string(), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !approximate.is_empty() {
|
|
||||||
options.insert(
|
|
||||||
"user_location".to_string(),
|
|
||||||
json!({
|
|
||||||
"type": "approximate",
|
|
||||||
"approximate": approximate,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
return Some(Value::Object(options));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_claude_tool_choice_to_openai(tool_choice: Option<&Value>) -> Option<Option<Value>> {
|
|
||||||
let Some(tool_choice) = tool_choice else {
|
|
||||||
return Some(None);
|
|
||||||
};
|
|
||||||
match tool_choice {
|
|
||||||
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"auto" => Some(Some(Value::String("auto".to_string()))),
|
|
||||||
"any" => Some(Some(Value::String("required".to_string()))),
|
|
||||||
"none" => Some(Some(Value::String("none".to_string()))),
|
|
||||||
_ => Some(None),
|
|
||||||
},
|
|
||||||
Value::Object(value) => {
|
|
||||||
if let Some(name) = value.get("name").and_then(Value::as_str) {
|
|
||||||
return Some(Some(json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": { "name": name }
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
let kind = value
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
match kind.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"auto" => Some(Some(Value::String("auto".to_string()))),
|
|
||||||
"any" => Some(Some(Value::String("required".to_string()))),
|
|
||||||
"none" => Some(Some(Value::String("none".to_string()))),
|
|
||||||
"tool" => value
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|name| {
|
|
||||||
Some(json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": { "name": name }
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.or(Some(None)),
|
|
||||||
_ => Some(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Some(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_claude_parallel_tool_calls(tool_choice: Option<&Value>) -> Option<bool> {
|
|
||||||
let tool_choice = tool_choice?.as_object()?;
|
|
||||||
let choice_type = tool_choice
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if choice_type == "none" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tool_choice
|
|
||||||
.get("disable_parallel_tool_use")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.map(|value| !value)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::normalize_claude_request_to_openai_chat_request;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assigns_deterministic_tool_use_ids_when_claude_blocks_omit_ids() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "tool_use",
|
|
||||||
"name": "search",
|
|
||||||
"input": {"query": "alpha"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tool_use",
|
|
||||||
"name": "search",
|
|
||||||
"input": {"query": "beta"}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
let first = normalize_claude_request_to_openai_chat_request(&request)
|
|
||||||
.expect("request should convert");
|
|
||||||
let second = normalize_claude_request_to_openai_chat_request(&request)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(first, second);
|
|
||||||
assert_eq!(first["messages"][0]["tool_calls"][0]["id"], "toolu_auto_0");
|
|
||||||
assert_eq!(first["messages"][0]["tool_calls"][1]["id"], "toolu_auto_1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_explicit_claude_tool_use_ids() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": "toolu_explicit_1",
|
|
||||||
"name": "search",
|
|
||||||
"input": {"query": "alpha"}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
let normalized = normalize_claude_request_to_openai_chat_request(&request)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][0]["tool_calls"][0]["id"],
|
|
||||||
"toolu_explicit_1"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extracts_claude_web_search_and_parallel_settings() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"type": "web_search_20250305",
|
|
||||||
"name": "web_search",
|
|
||||||
"max_uses": 10,
|
|
||||||
"user_location": {
|
|
||||||
"type": "approximate",
|
|
||||||
"city": "Shanghai",
|
|
||||||
"country": "CN",
|
|
||||||
"timezone": "Asia/Shanghai"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tool_choice": {
|
|
||||||
"type": "auto",
|
|
||||||
"disable_parallel_tool_use": true
|
|
||||||
},
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "find something"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
let normalized = normalize_claude_request_to_openai_chat_request(&request)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
normalized["web_search_options"]["search_context_size"],
|
|
||||||
"high"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalized["web_search_options"]["user_location"],
|
|
||||||
json!({
|
|
||||||
"type": "approximate",
|
|
||||||
"approximate": {
|
|
||||||
"city": "Shanghai",
|
|
||||||
"country": "CN",
|
|
||||||
"timezone": "Asia/Shanghai"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(normalized["parallel_tool_calls"], false);
|
|
||||||
assert!(normalized.get("tools").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_claude_media_thinking_and_stop_sequences() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{ "type": "text", "text": "See attachment" },
|
|
||||||
{
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "url",
|
|
||||||
"url": "https://example.com/cat.png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": "application/pdf",
|
|
||||||
"data": "JVBERi0x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "need context first",
|
|
||||||
"signature": "sig_123"
|
|
||||||
},
|
|
||||||
{ "type": "redacted_thinking", "data": "redacted_blob" },
|
|
||||||
{ "type": "text", "text": "Working on it" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"stop_sequences": ["END"],
|
|
||||||
"output_config": { "effort": "max" }
|
|
||||||
});
|
|
||||||
|
|
||||||
let normalized = normalize_claude_request_to_openai_chat_request(&request)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(normalized["stop"], json!(["END"]));
|
|
||||||
assert_eq!(normalized["reasoning_effort"], "xhigh");
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][0]["content"],
|
|
||||||
json!([
|
|
||||||
{ "type": "text", "text": "See attachment" },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": { "url": "https://example.com/cat.png" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][1]["reasoning_content"],
|
|
||||||
"need context first"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][1]["reasoning_parts"],
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "need context first",
|
|
||||||
"signature": "sig_123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": "redacted_blob"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(normalized["messages"][1]["content"], "Working on it");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_default_claude_web_search_tool_as_empty_openai_options() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"type": "web_search_20250305",
|
|
||||||
"name": "web_search"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "find something"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
let normalized = normalize_claude_request_to_openai_chat_request(&request)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(normalized["web_search_options"], json!({}));
|
|
||||||
assert!(normalized.get("tools").is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,962 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::shared::canonical_json_string;
|
|
||||||
use crate::planner::openai::map_thinking_budget_to_openai_reasoning_effort;
|
|
||||||
|
|
||||||
const GEMINI_MAPPED_GENERATION_CONFIG_KEYS: &[&str] = &[
|
|
||||||
"maxOutputTokens",
|
|
||||||
"max_output_tokens",
|
|
||||||
"temperature",
|
|
||||||
"topP",
|
|
||||||
"top_p",
|
|
||||||
"topK",
|
|
||||||
"top_k",
|
|
||||||
"candidateCount",
|
|
||||||
"candidate_count",
|
|
||||||
"seed",
|
|
||||||
"stopSequences",
|
|
||||||
"stop_sequences",
|
|
||||||
"thinkingConfig",
|
|
||||||
"thinking_config",
|
|
||||||
"responseMimeType",
|
|
||||||
"response_mime_type",
|
|
||||||
"responseSchema",
|
|
||||||
"response_schema",
|
|
||||||
"responseModalities",
|
|
||||||
"response_modalities",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn normalize_gemini_request_to_openai_chat_request(
|
|
||||||
body_json: &Value,
|
|
||||||
request_path: &str,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let request = body_json.as_object()?;
|
|
||||||
let mut output = Map::new();
|
|
||||||
if let Some(model) = request
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
output.insert("model".to_string(), Value::String(model.to_string()));
|
|
||||||
} else if let Some(model) = extract_gemini_model_from_path(request_path) {
|
|
||||||
output.insert("model".to_string(), Value::String(model));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
if let Some(system_text) = extract_gemini_system_text(
|
|
||||||
request
|
|
||||||
.get("systemInstruction")
|
|
||||||
.or_else(|| request.get("system_instruction")),
|
|
||||||
) {
|
|
||||||
if !system_text.trim().is_empty() {
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "system",
|
|
||||||
"content": system_text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(contents) = request.get("contents").and_then(Value::as_array) {
|
|
||||||
for content in contents {
|
|
||||||
let content_object = content.as_object()?;
|
|
||||||
let role = content_object
|
|
||||||
.get("role")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("user")
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
let parts = content_object.get("parts").and_then(Value::as_array)?;
|
|
||||||
match role.as_str() {
|
|
||||||
"model" => messages.push(normalize_gemini_model_parts_to_openai_message(parts)?),
|
|
||||||
_ => append_gemini_user_parts_to_openai_messages(parts, &mut messages)?,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.insert("messages".to_string(), Value::Array(messages));
|
|
||||||
|
|
||||||
let generation_config = request
|
|
||||||
.get("generationConfig")
|
|
||||||
.or_else(|| request.get("generation_config"))
|
|
||||||
.and_then(Value::as_object);
|
|
||||||
let mut google_extra = Map::new();
|
|
||||||
let mut gemini_extra = Map::new();
|
|
||||||
if let Some(generation_config) = generation_config {
|
|
||||||
if let Some(value) =
|
|
||||||
generation_config_value(generation_config, "maxOutputTokens", "max_output_tokens")
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
output.insert("max_completion_tokens".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) = generation_config.get("temperature").cloned() {
|
|
||||||
output.insert("temperature".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) = generation_config_value(generation_config, "topP", "top_p").cloned() {
|
|
||||||
output.insert("top_p".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) = generation_config_value(generation_config, "topK", "top_k").cloned() {
|
|
||||||
output.insert("top_k".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) =
|
|
||||||
generation_config_value(generation_config, "candidateCount", "candidate_count").cloned()
|
|
||||||
{
|
|
||||||
output.insert("n".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) = generation_config.get("seed").cloned() {
|
|
||||||
output.insert("seed".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) =
|
|
||||||
generation_config_value(generation_config, "stopSequences", "stop_sequences").cloned()
|
|
||||||
{
|
|
||||||
output.insert("stop".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(thinking_config) =
|
|
||||||
generation_config_value(generation_config, "thinkingConfig", "thinking_config")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
google_extra.insert(
|
|
||||||
"thinking_config".to_string(),
|
|
||||||
Value::Object(thinking_config.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(response_modalities) = generation_config_value(
|
|
||||||
generation_config,
|
|
||||||
"responseModalities",
|
|
||||||
"response_modalities",
|
|
||||||
)
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
google_extra.insert("response_modalities".to_string(), response_modalities);
|
|
||||||
}
|
|
||||||
if let Some(thinking_budget) =
|
|
||||||
generation_config_value(generation_config, "thinkingConfig", "thinking_config")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|thinking| {
|
|
||||||
thinking
|
|
||||||
.get("thinkingBudget")
|
|
||||||
.or_else(|| thinking.get("thinking_budget"))
|
|
||||||
})
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
{
|
|
||||||
output.insert(
|
|
||||||
"reasoning_effort".to_string(),
|
|
||||||
Value::String(
|
|
||||||
map_thinking_budget_to_openai_reasoning_effort(thinking_budget).to_string(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if generation_config_value(generation_config, "responseMimeType", "response_mime_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value == "application/json")
|
|
||||||
{
|
|
||||||
let response_format = if let Some(schema) =
|
|
||||||
generation_config_value(generation_config, "responseSchema", "response_schema")
|
|
||||||
{
|
|
||||||
json!({
|
|
||||||
"type": "json_schema",
|
|
||||||
"json_schema": {
|
|
||||||
"name": "response_schema",
|
|
||||||
"schema": schema,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
json!({ "type": "json_object" })
|
|
||||||
};
|
|
||||||
output.insert("response_format".to_string(), response_format);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut generation_config_extra = Map::new();
|
|
||||||
for (key, value) in generation_config {
|
|
||||||
if GEMINI_MAPPED_GENERATION_CONFIG_KEYS
|
|
||||||
.iter()
|
|
||||||
.any(|candidate| candidate == &key.as_str())
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
generation_config_extra.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
if !generation_config_extra.is_empty() {
|
|
||||||
gemini_extra.insert(
|
|
||||||
"generation_config_extra".to_string(),
|
|
||||||
Value::Object(generation_config_extra),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(value) = request.get("stream").cloned() {
|
|
||||||
output.insert("stream".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) = request
|
|
||||||
.get("safetySettings")
|
|
||||||
.or_else(|| request.get("safety_settings"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
gemini_extra.insert("safety_settings".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(value) = request
|
|
||||||
.get("cachedContent")
|
|
||||||
.or_else(|| request.get("cached_content"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
gemini_extra.insert("cached_content".to_string(), value);
|
|
||||||
}
|
|
||||||
if let Some(tools) = normalize_gemini_tools_to_openai(request.get("tools"))? {
|
|
||||||
output.insert("tools".to_string(), Value::Array(tools));
|
|
||||||
}
|
|
||||||
if let Some(web_search_options) = extract_gemini_web_search_options(request.get("tools")) {
|
|
||||||
output.insert("web_search_options".to_string(), web_search_options);
|
|
||||||
}
|
|
||||||
if let Some(tool_choice) = normalize_gemini_tool_choice_to_openai(
|
|
||||||
request
|
|
||||||
.get("toolConfig")
|
|
||||||
.or_else(|| request.get("tool_config")),
|
|
||||||
)? {
|
|
||||||
output.insert("tool_choice".to_string(), tool_choice);
|
|
||||||
}
|
|
||||||
if !google_extra.is_empty() || !gemini_extra.is_empty() {
|
|
||||||
let mut extra_body = Map::new();
|
|
||||||
if !google_extra.is_empty() {
|
|
||||||
extra_body.insert("google".to_string(), Value::Object(google_extra));
|
|
||||||
}
|
|
||||||
if !gemini_extra.is_empty() {
|
|
||||||
extra_body.insert("gemini".to_string(), Value::Object(gemini_extra));
|
|
||||||
}
|
|
||||||
output.insert("extra_body".to_string(), Value::Object(extra_body));
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Value::Object(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum GeminiNormalizedPart {
|
|
||||||
Text(String),
|
|
||||||
Thinking {
|
|
||||||
text: String,
|
|
||||||
signature: Option<String>,
|
|
||||||
},
|
|
||||||
ImageUrl(String),
|
|
||||||
FileData(String),
|
|
||||||
FileUrl(String),
|
|
||||||
AudioData {
|
|
||||||
data: String,
|
|
||||||
format: String,
|
|
||||||
},
|
|
||||||
ToolUse {
|
|
||||||
id: Option<String>,
|
|
||||||
name: String,
|
|
||||||
input: Value,
|
|
||||||
},
|
|
||||||
ToolResult {
|
|
||||||
tool_use_id: String,
|
|
||||||
content: Value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_gemini_user_parts_to_openai_messages(
|
|
||||||
parts: &[Value],
|
|
||||||
messages: &mut Vec<Value>,
|
|
||||||
) -> Option<()> {
|
|
||||||
let mut pending_parts = Vec::new();
|
|
||||||
for part in normalize_gemini_parts(parts)? {
|
|
||||||
match part {
|
|
||||||
GeminiNormalizedPart::Text(text) | GeminiNormalizedPart::Thinking { text, .. } => {
|
|
||||||
push_openai_text_part(&mut pending_parts, text);
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::ImageUrl(url) => {
|
|
||||||
pending_parts.push(build_openai_image_part(url));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::FileData(file_data) => {
|
|
||||||
pending_parts.push(build_openai_file_part(file_data));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::FileUrl(url) => {
|
|
||||||
push_openai_text_part(&mut pending_parts, format!("[File: {url}]"));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::AudioData { data, format } => {
|
|
||||||
pending_parts.push(build_openai_audio_part(data, format));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
} => {
|
|
||||||
flush_openai_user_content_parts(&mut pending_parts, messages);
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_use_id,
|
|
||||||
"content": content,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::ToolUse { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush_openai_user_content_parts(&mut pending_parts, messages);
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_gemini_model_parts_to_openai_message(parts: &[Value]) -> Option<Value> {
|
|
||||||
let mut reasoning_segments = Vec::new();
|
|
||||||
let mut reasoning_parts = Vec::new();
|
|
||||||
let mut content_parts = Vec::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
for (index, part) in normalize_gemini_parts(parts)?.into_iter().enumerate() {
|
|
||||||
match part {
|
|
||||||
GeminiNormalizedPart::Text(text) => {
|
|
||||||
push_openai_text_part(&mut content_parts, text);
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::Thinking { text, signature } => {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
reasoning_segments.push(text.clone());
|
|
||||||
}
|
|
||||||
let mut reasoning_part = Map::new();
|
|
||||||
reasoning_part.insert("type".to_string(), Value::String("thinking".to_string()));
|
|
||||||
reasoning_part.insert("thinking".to_string(), Value::String(text));
|
|
||||||
if let Some(signature) = signature {
|
|
||||||
reasoning_part.insert("signature".to_string(), Value::String(signature));
|
|
||||||
}
|
|
||||||
reasoning_parts.push(Value::Object(reasoning_part));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::ImageUrl(url) => {
|
|
||||||
content_parts.push(build_openai_image_part(url));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::FileData(file_data) => {
|
|
||||||
content_parts.push(build_openai_file_part(file_data));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::FileUrl(url) => {
|
|
||||||
push_openai_text_part(&mut content_parts, format!("[File: {url}]"));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::AudioData { data, format } => {
|
|
||||||
content_parts.push(build_openai_audio_part(data, format));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::ToolUse { id, name, input } => {
|
|
||||||
let tool_id = id.unwrap_or_else(|| format!("toolu_{}_{}", name, index));
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": name,
|
|
||||||
"arguments": canonical_json_string(input),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
GeminiNormalizedPart::ToolResult { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut assistant = Map::new();
|
|
||||||
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
assistant.insert(
|
|
||||||
"content".to_string(),
|
|
||||||
match build_openai_content_value(content_parts) {
|
|
||||||
Some(content) => content,
|
|
||||||
None if !tool_calls.is_empty() => Value::Null,
|
|
||||||
None => Value::String(String::new()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if !reasoning_segments.is_empty() {
|
|
||||||
assistant.insert(
|
|
||||||
"reasoning_content".to_string(),
|
|
||||||
Value::String(reasoning_segments.join("")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !reasoning_parts.is_empty() {
|
|
||||||
assistant.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
|
|
||||||
}
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
Some(Value::Object(assistant))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_gemini_parts(parts: &[Value]) -> Option<Vec<GeminiNormalizedPart>> {
|
|
||||||
let mut normalized = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
|
||||||
if part
|
|
||||||
.get("thought")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
normalized.push(GeminiNormalizedPart::Thinking {
|
|
||||||
text: text.to_string(),
|
|
||||||
signature: part
|
|
||||||
.get("thoughtSignature")
|
|
||||||
.or_else(|| part.get("thought_signature"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
normalized.push(GeminiNormalizedPart::Text(text.to_string()));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(inline_data) = part
|
|
||||||
.get("inlineData")
|
|
||||||
.or_else(|| part.get("inline_data"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let mime_type = inline_data
|
|
||||||
.get("mimeType")
|
|
||||||
.or_else(|| inline_data.get("mime_type"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let data = inline_data
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
if mime_type.starts_with("image/") {
|
|
||||||
normalized.push(GeminiNormalizedPart::ImageUrl(build_data_url(
|
|
||||||
mime_type, data,
|
|
||||||
)));
|
|
||||||
} else if let Some(format) = mime_type.strip_prefix("audio/") {
|
|
||||||
normalized.push(GeminiNormalizedPart::AudioData {
|
|
||||||
data: data.to_string(),
|
|
||||||
format: format.to_string(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
normalized.push(GeminiNormalizedPart::FileData(build_data_url(
|
|
||||||
mime_type, data,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(file_data) = part
|
|
||||||
.get("fileData")
|
|
||||||
.or_else(|| part.get("file_data"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let file_uri = file_data
|
|
||||||
.get("fileUri")
|
|
||||||
.or_else(|| file_data.get("file_uri"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let mime_type = file_data
|
|
||||||
.get("mimeType")
|
|
||||||
.or_else(|| file_data.get("mime_type"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
if mime_type.is_some_and(|value| value.starts_with("image/")) {
|
|
||||||
normalized.push(GeminiNormalizedPart::ImageUrl(file_uri.to_string()));
|
|
||||||
} else {
|
|
||||||
normalized.push(GeminiNormalizedPart::FileUrl(file_uri.to_string()));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(function_call) = part
|
|
||||||
.get("functionCall")
|
|
||||||
.or_else(|| part.get("function_call"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let name = function_call
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?
|
|
||||||
.to_string();
|
|
||||||
normalized.push(GeminiNormalizedPart::ToolUse {
|
|
||||||
id: function_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
name,
|
|
||||||
input: function_call
|
|
||||||
.get("args")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| Value::Object(Map::new())),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(function_response) = part
|
|
||||||
.get("functionResponse")
|
|
||||||
.or_else(|| part.get("function_response"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let tool_use_id = function_response
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| {
|
|
||||||
function_response
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})?;
|
|
||||||
let response_value = function_response
|
|
||||||
.get("response")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| Value::Object(Map::new()));
|
|
||||||
let content = match response_value {
|
|
||||||
Value::Object(mut object) => object
|
|
||||||
.remove("result")
|
|
||||||
.unwrap_or_else(|| Value::Object(object)),
|
|
||||||
other => other,
|
|
||||||
};
|
|
||||||
normalized.push(GeminiNormalizedPart::ToolResult {
|
|
||||||
tool_use_id,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush_openai_user_content_parts(pending_parts: &mut Vec<Value>, messages: &mut Vec<Value>) {
|
|
||||||
let parts = std::mem::take(pending_parts);
|
|
||||||
let Some(content) = build_openai_content_value(parts) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
messages.push(json!({
|
|
||||||
"role": "user",
|
|
||||||
"content": content,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_content_value(parts: Vec<Value>) -> Option<Value> {
|
|
||||||
if parts.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if parts
|
|
||||||
.iter()
|
|
||||||
.all(|part| part.get("type").and_then(Value::as_str) == Some("text"))
|
|
||||||
{
|
|
||||||
let text = parts
|
|
||||||
.iter()
|
|
||||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n\n");
|
|
||||||
return Some(Value::String(text));
|
|
||||||
}
|
|
||||||
Some(Value::Array(parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_openai_text_part(parts: &mut Vec<Value>, text: String) {
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_image_part(url: String) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": url,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_file_part(file_data: String) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": file_data,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_audio_part(data: String, format: String) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": data,
|
|
||||||
"format": format,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_data_url(mime_type: &str, data: &str) -> String {
|
|
||||||
format!("data:{mime_type};base64,{data}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generation_config_value<'a>(
|
|
||||||
generation_config: &'a Map<String, Value>,
|
|
||||||
camel: &str,
|
|
||||||
snake: &str,
|
|
||||||
) -> Option<&'a Value> {
|
|
||||||
generation_config
|
|
||||||
.get(camel)
|
|
||||||
.or_else(|| generation_config.get(snake))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_gemini_system_text(system_instruction: Option<&Value>) -> Option<String> {
|
|
||||||
let system_instruction = system_instruction?;
|
|
||||||
match system_instruction {
|
|
||||||
Value::String(text) => Some(text.trim().to_string()),
|
|
||||||
Value::Object(object) => {
|
|
||||||
let parts = object.get("parts")?.as_array()?;
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
segments.push(text.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(segments.join("\n\n"))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_gemini_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
|
|
||||||
let Some(tools) = tools else {
|
|
||||||
return Some(None);
|
|
||||||
};
|
|
||||||
let tools = tools.as_array()?;
|
|
||||||
let mut normalized = Vec::new();
|
|
||||||
let mut has_code_execution = false;
|
|
||||||
let mut has_url_context = false;
|
|
||||||
for tool in tools {
|
|
||||||
let tool = tool.as_object()?;
|
|
||||||
if tool.get("codeExecution").is_some() || tool.get("code_execution").is_some() {
|
|
||||||
has_code_execution = true;
|
|
||||||
}
|
|
||||||
if tool.get("urlContext").is_some() || tool.get("url_context").is_some() {
|
|
||||||
has_url_context = true;
|
|
||||||
}
|
|
||||||
let declarations = tool
|
|
||||||
.get("functionDeclarations")
|
|
||||||
.or_else(|| tool.get("function_declarations"))
|
|
||||||
.and_then(Value::as_array);
|
|
||||||
let Some(declarations) = declarations else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for declaration in declarations {
|
|
||||||
let declaration = declaration.as_object()?;
|
|
||||||
let name = declaration
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let mut function = Map::new();
|
|
||||||
function.insert("name".to_string(), Value::String(name.to_string()));
|
|
||||||
if let Some(description) = declaration.get("description").and_then(Value::as_str) {
|
|
||||||
if !description.trim().is_empty() {
|
|
||||||
function.insert(
|
|
||||||
"description".to_string(),
|
|
||||||
Value::String(description.trim().to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function.insert(
|
|
||||||
"parameters".to_string(),
|
|
||||||
declaration
|
|
||||||
.get("parameters")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| json!({"type": "object"})),
|
|
||||||
);
|
|
||||||
normalized.push(json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": Value::Object(function),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if has_code_execution {
|
|
||||||
normalized.push(build_openai_builtin_gemini_tool("codeExecution"));
|
|
||||||
}
|
|
||||||
if has_url_context {
|
|
||||||
normalized.push(build_openai_builtin_gemini_tool("urlContext"));
|
|
||||||
}
|
|
||||||
if normalized.is_empty() {
|
|
||||||
Some(None)
|
|
||||||
} else {
|
|
||||||
Some(Some(normalized))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_gemini_tool_choice_to_openai(tool_config: Option<&Value>) -> Option<Option<Value>> {
|
|
||||||
let Some(tool_config) = tool_config else {
|
|
||||||
return Some(None);
|
|
||||||
};
|
|
||||||
let tool_config = tool_config.as_object()?;
|
|
||||||
let function_config = tool_config
|
|
||||||
.get("functionCallingConfig")
|
|
||||||
.or_else(|| tool_config.get("function_calling_config"))
|
|
||||||
.and_then(Value::as_object)?;
|
|
||||||
let mode = function_config
|
|
||||||
.get("mode")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_uppercase();
|
|
||||||
if let Some(name) = function_config
|
|
||||||
.get("allowedFunctionNames")
|
|
||||||
.or_else(|| function_config.get("allowed_function_names"))
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.and_then(|values| values.first())
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
return Some(Some(json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": { "name": name }
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
match mode.as_str() {
|
|
||||||
"NONE" => Some(Some(Value::String("none".to_string()))),
|
|
||||||
"AUTO" => Some(Some(Value::String("auto".to_string()))),
|
|
||||||
"ANY" | "REQUIRED" => Some(Some(Value::String("required".to_string()))),
|
|
||||||
_ => Some(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_gemini_web_search_options(tools: Option<&Value>) -> Option<Value> {
|
|
||||||
let tools = tools?.as_array()?;
|
|
||||||
for tool in tools {
|
|
||||||
let tool = tool.as_object()?;
|
|
||||||
if tool.get("googleSearch").is_some() || tool.get("google_search").is_some() {
|
|
||||||
return Some(json!({}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_builtin_gemini_tool(name: &str) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": name,
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
|
|
||||||
let marker = "/models/";
|
|
||||||
let start = path.find(marker)? + marker.len();
|
|
||||||
let tail = &path[start..];
|
|
||||||
let end = tail.find(':').unwrap_or(tail.len());
|
|
||||||
let model = tail[..end].trim();
|
|
||||||
if model.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(model.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::normalize_gemini_request_to_openai_chat_request;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_gemini_seed_builtin_tools_and_specific_tool_choice() {
|
|
||||||
let request = json!({
|
|
||||||
"model": "gemini-2.5-pro",
|
|
||||||
"contents": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"parts": [{ "text": "use tools" }]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"generationConfig": {
|
|
||||||
"maxOutputTokens": 256,
|
|
||||||
"topK": 20,
|
|
||||||
"seed": 7
|
|
||||||
},
|
|
||||||
"tools": [
|
|
||||||
{ "googleSearch": {} },
|
|
||||||
{ "codeExecution": {} },
|
|
||||||
{ "urlContext": {} },
|
|
||||||
{
|
|
||||||
"functionDeclarations": [
|
|
||||||
{
|
|
||||||
"name": "lookupWeather",
|
|
||||||
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"toolConfig": {
|
|
||||||
"functionCallingConfig": {
|
|
||||||
"mode": "ANY",
|
|
||||||
"allowedFunctionNames": ["lookupWeather"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let normalized = normalize_gemini_request_to_openai_chat_request(
|
|
||||||
&request,
|
|
||||||
"/v1beta/models/gemini:generateContent",
|
|
||||||
)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(normalized["max_completion_tokens"], 256);
|
|
||||||
assert_eq!(normalized["top_k"], 20);
|
|
||||||
assert_eq!(normalized["seed"], 7);
|
|
||||||
assert_eq!(normalized["web_search_options"], json!({}));
|
|
||||||
assert_eq!(
|
|
||||||
normalized["tool_choice"],
|
|
||||||
json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": { "name": "lookupWeather" }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalized["tools"],
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "lookupWeather",
|
|
||||||
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "codeExecution",
|
|
||||||
"parameters": { "type": "object", "properties": {} }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "urlContext",
|
|
||||||
"parameters": { "type": "object", "properties": {} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_gemini_multimodal_thought_and_passthrough_config() {
|
|
||||||
let request = json!({
|
|
||||||
"contents": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"parts": [
|
|
||||||
{ "text": "Look at these" },
|
|
||||||
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } },
|
|
||||||
{ "inline_data": { "mime_type": "application/pdf", "data": "JVBERi0x" } },
|
|
||||||
{ "inlineData": { "mimeType": "audio/mp3", "data": "SUQz" } },
|
|
||||||
{
|
|
||||||
"functionResponse": {
|
|
||||||
"name": "lookup",
|
|
||||||
"id": "call_1",
|
|
||||||
"response": { "result": { "city": "Shanghai" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "model",
|
|
||||||
"parts": [
|
|
||||||
{ "text": "reasoning", "thought": true, "thoughtSignature": "sig_123" },
|
|
||||||
{ "text": "done" },
|
|
||||||
{ "fileData": { "fileUri": "https://example.com/cat.png", "mimeType": "image/png" } },
|
|
||||||
{ "fileData": { "fileUri": "https://example.com/report.pdf", "mimeType": "application/pdf" } },
|
|
||||||
{
|
|
||||||
"functionCall": {
|
|
||||||
"name": "lookup",
|
|
||||||
"id": "call_1",
|
|
||||||
"args": { "city": "Shanghai" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"generation_config": {
|
|
||||||
"max_output_tokens": 128,
|
|
||||||
"stop_sequences": ["END"],
|
|
||||||
"thinking_config": {
|
|
||||||
"includeThoughts": true,
|
|
||||||
"thinkingBudget": 4096
|
|
||||||
},
|
|
||||||
"responseModalities": ["TEXT", "IMAGE"],
|
|
||||||
"candidate_count": 2,
|
|
||||||
"presencePenalty": 0.5
|
|
||||||
},
|
|
||||||
"safetySettings": [{ "category": "HARM_CATEGORY_HATE_SPEECH" }],
|
|
||||||
"cachedContent": "cached/123"
|
|
||||||
});
|
|
||||||
|
|
||||||
let normalized = normalize_gemini_request_to_openai_chat_request(
|
|
||||||
&request,
|
|
||||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
|
||||||
)
|
|
||||||
.expect("request should convert");
|
|
||||||
|
|
||||||
assert_eq!(normalized["model"], "gemini-2.5-pro");
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][2]["reasoning_parts"],
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "reasoning",
|
|
||||||
"signature": "sig_123"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][0]["content"],
|
|
||||||
json!([
|
|
||||||
{ "type": "text", "text": "Look at these" },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": { "url": "data:image/png;base64,iVBORw0KGgo=" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": { "file_data": "data:application/pdf;base64,JVBERi0x" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": { "data": "SUQz", "format": "mp3" }
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(normalized["messages"][1]["role"], "tool");
|
|
||||||
assert_eq!(normalized["messages"][1]["tool_call_id"], "call_1");
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][1]["content"],
|
|
||||||
json!({ "city": "Shanghai" })
|
|
||||||
);
|
|
||||||
assert_eq!(normalized["messages"][2]["reasoning_content"], "reasoning");
|
|
||||||
assert_eq!(
|
|
||||||
normalized["messages"][2]["content"],
|
|
||||||
json!([
|
|
||||||
{ "type": "text", "text": "done" },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": { "url": "https://example.com/cat.png" }
|
|
||||||
},
|
|
||||||
{ "type": "text", "text": "[File: https://example.com/report.pdf]" }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(normalized["messages"][2]["tool_calls"][0]["id"], "call_1");
|
|
||||||
assert_eq!(normalized["max_completion_tokens"], 128);
|
|
||||||
assert_eq!(normalized["n"], 2);
|
|
||||||
assert_eq!(normalized["stop"], json!(["END"]));
|
|
||||||
assert_eq!(normalized["reasoning_effort"], "high");
|
|
||||||
assert_eq!(
|
|
||||||
normalized["extra_body"]["google"]["response_modalities"],
|
|
||||||
json!(["TEXT", "IMAGE"])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalized["extra_body"]["gemini"]["generation_config_extra"]["presencePenalty"],
|
|
||||||
0.5
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalized["extra_body"]["gemini"]["cached_content"],
|
|
||||||
"cached/123"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
mod claude;
|
|
||||||
mod gemini;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub use claude::normalize_claude_request_to_openai_chat_request;
|
|
||||||
pub use gemini::normalize_gemini_request_to_openai_chat_request;
|
|
||||||
pub use shared::{extract_openai_text_content, parse_openai_tool_result_content};
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
|
|
||||||
match content {
|
|
||||||
None | Some(Value::Null) => Some(String::new()),
|
|
||||||
Some(Value::String(text)) => Some(text.clone()),
|
|
||||||
Some(Value::Array(parts)) => {
|
|
||||||
let mut collected = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part_object = part.as_object()?;
|
|
||||||
let part_type = part_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if matches!(part_type, "text" | "input_text") {
|
|
||||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
collected.push(text.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(collected.join("\n"))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
|
|
||||||
match content {
|
|
||||||
Some(Value::String(raw)) => {
|
|
||||||
let trimmed = raw.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
Value::String(String::new())
|
|
||||||
} else {
|
|
||||||
serde_json::from_str::<Value>(trimmed)
|
|
||||||
.unwrap_or_else(|_| Value::String(raw.clone()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Array(parts)) => {
|
|
||||||
let texts = parts
|
|
||||||
.iter()
|
|
||||||
.filter_map(|part| {
|
|
||||||
part.as_object()
|
|
||||||
.and_then(|object| object.get("text"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if texts.is_empty() {
|
|
||||||
Value::Array(parts.clone())
|
|
||||||
} else {
|
|
||||||
Value::String(texts.join("\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(value) => value.clone(),
|
|
||||||
None => Value::String(String::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn canonical_json_string(value: Value) -> String {
|
|
||||||
match value {
|
|
||||||
Value::String(text) => text,
|
|
||||||
other => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use super::shared::{build_generated_tool_call_id, parse_openai_function_arguments};
|
|
||||||
|
|
||||||
pub fn convert_openai_chat_response_to_claude_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let choices = body.get("choices")?.as_array()?;
|
|
||||||
let first_choice = choices.first()?.as_object()?;
|
|
||||||
let message = first_choice.get("message")?.as_object()?;
|
|
||||||
let mut content = extract_openai_reasoning_to_claude_blocks(message);
|
|
||||||
content.extend(convert_openai_assistant_content_to_claude_blocks(
|
|
||||||
message.get("content"),
|
|
||||||
)?);
|
|
||||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
for (index, tool_call) in tool_call_values.iter().enumerate() {
|
|
||||||
let tool_call = tool_call.as_object()?;
|
|
||||||
let function = tool_call.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let tool_id = tool_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let input = parse_openai_function_arguments(function.get("arguments"))?;
|
|
||||||
content.push(json!({
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": tool_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"input": input,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if content.is_empty() {
|
|
||||||
content.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
|
|
||||||
Some("stop") | None => "end_turn",
|
|
||||||
Some("length") => "max_tokens",
|
|
||||||
Some("tool_calls") | Some("function_call") => "tool_use",
|
|
||||||
Some("content_filter") => "content_filtered",
|
|
||||||
Some(other) => other,
|
|
||||||
};
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let input_tokens = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("msg-local-finalize");
|
|
||||||
|
|
||||||
Some(json!({
|
|
||||||
"id": id,
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"model": model,
|
|
||||||
"content": content,
|
|
||||||
"stop_reason": stop_reason,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": input_tokens,
|
|
||||||
"output_tokens": output_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.map(|mut response| {
|
|
||||||
if let Some(cached_tokens) = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens_details"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|details| details.get("cached_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
{
|
|
||||||
response["usage"]["cache_read_input_tokens"] = Value::from(cached_tokens);
|
|
||||||
}
|
|
||||||
if let Some(cached_creation_tokens) = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens_details"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|details| details.get("cached_creation_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
{
|
|
||||||
response["usage"]["cache_creation_input_tokens"] = Value::from(cached_creation_tokens);
|
|
||||||
}
|
|
||||||
response
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_reasoning_to_claude_blocks(
|
|
||||||
message: &serde_json::Map<String, Value>,
|
|
||||||
) -> Vec<Value> {
|
|
||||||
let mut blocks = Vec::new();
|
|
||||||
if let Some(reasoning_parts) = message.get("reasoning_parts").and_then(Value::as_array) {
|
|
||||||
for reasoning_part in reasoning_parts {
|
|
||||||
let Some(reasoning_object) = reasoning_part.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match reasoning_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("thinking")
|
|
||||||
{
|
|
||||||
"thinking" => {
|
|
||||||
let thinking = reasoning_object
|
|
||||||
.get("thinking")
|
|
||||||
.or_else(|| reasoning_object.get("text"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if thinking.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut block = serde_json::Map::new();
|
|
||||||
block.insert("type".to_string(), Value::String("thinking".to_string()));
|
|
||||||
block.insert("thinking".to_string(), Value::String(thinking.to_string()));
|
|
||||||
if let Some(signature) = reasoning_object
|
|
||||||
.get("signature")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
block.insert(
|
|
||||||
"signature".to_string(),
|
|
||||||
Value::String(signature.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
blocks.push(Value::Object(block));
|
|
||||||
}
|
|
||||||
"redacted_thinking" => {
|
|
||||||
let data = reasoning_object
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !data.is_empty() {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !blocks.is_empty() {
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
if let Some(reasoning_content) = message
|
|
||||||
.get("reasoning_content")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": reasoning_content,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_openai_assistant_content_to_claude_blocks(
|
|
||||||
content: Option<&Value>,
|
|
||||||
) -> Option<Vec<Value>> {
|
|
||||||
match content {
|
|
||||||
None | Some(Value::Null) => Some(Vec::new()),
|
|
||||||
Some(Value::String(text)) => {
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
Some(Vec::new())
|
|
||||||
} else {
|
|
||||||
Some(vec![json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
})])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Array(parts)) => {
|
|
||||||
let mut blocks = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
let part_type = part
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match part_type.as_str() {
|
|
||||||
"text" | "output_text" => {
|
|
||||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"image_url" | "output_image" => {
|
|
||||||
if let Some(url) = extract_openai_image_url(part) {
|
|
||||||
if let Some((media_type, data)) = parse_data_url(url.as_str()) {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": media_type,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "url",
|
|
||||||
"url": url,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"file" => {
|
|
||||||
let file = part.get("file").and_then(Value::as_object).unwrap_or(part);
|
|
||||||
if let Some(file_data) = file.get("file_data").and_then(Value::as_str) {
|
|
||||||
if let Some((media_type, data)) = parse_data_url(file_data) {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": media_type,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if let Some(file_id) = file
|
|
||||||
.get("file_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": format!("[File: {file_id}]"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"input_audio" => {
|
|
||||||
let audio = part
|
|
||||||
.get("input_audio")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part);
|
|
||||||
let data = audio
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let format = audio
|
|
||||||
.get("format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
if let (Some(data), Some(format)) = (data, format) {
|
|
||||||
blocks.push(json!({
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": format!("audio/{format}"),
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(blocks)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_image_url(part: &serde_json::Map<String, Value>) -> Option<String> {
|
|
||||||
part.get("image_url")
|
|
||||||
.and_then(|value| {
|
|
||||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
|
||||||
value
|
|
||||||
.as_object()
|
|
||||||
.and_then(|object| object.get("url"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
part.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
|
||||||
let rest = value.strip_prefix("data:")?;
|
|
||||||
let (meta, data) = rest.split_once(",")?;
|
|
||||||
let media_type = meta.strip_suffix(";base64")?;
|
|
||||||
if media_type.trim().is_empty() || data.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some((media_type.to_string(), data.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_openai_chat_response_to_claude_chat;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_openai_reasoning_and_cache_usage_in_claude_response() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "chatcmpl_123",
|
|
||||||
"model": "gpt-5.4",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "hello",
|
|
||||||
"reasoning_content": "step by step",
|
|
||||||
"reasoning_parts": [
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "step by step",
|
|
||||||
"signature": "sig_123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": "redacted_blob"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 11,
|
|
||||||
"completion_tokens": 7,
|
|
||||||
"prompt_tokens_details": {
|
|
||||||
"cached_tokens": 3,
|
|
||||||
"cached_creation_tokens": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_chat_response_to_claude_chat(&response, &json!({}))
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(converted["content"][0]["type"], "thinking");
|
|
||||||
assert_eq!(converted["content"][0]["thinking"], "step by step");
|
|
||||||
assert_eq!(converted["content"][0]["signature"], "sig_123");
|
|
||||||
assert_eq!(converted["content"][1]["type"], "redacted_thinking");
|
|
||||||
assert_eq!(converted["usage"]["cache_read_input_tokens"], 3);
|
|
||||||
assert_eq!(converted["usage"]["cache_creation_input_tokens"], 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_openai_multipart_content_into_claude_blocks() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "chatcmpl_img_123",
|
|
||||||
"model": "gpt-5.4",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{ "type": "text", "text": "See attached." },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": "data:image/png;base64,iVBORw0KGgo="
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_chat_response_to_claude_chat(&response, &json!({}))
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(converted["content"][0]["type"], "text");
|
|
||||||
assert_eq!(converted["content"][1]["type"], "image");
|
|
||||||
assert_eq!(converted["content"][1]["source"]["media_type"], "image/png");
|
|
||||||
assert_eq!(converted["content"][2]["type"], "document");
|
|
||||||
assert_eq!(
|
|
||||||
converted["content"][2]["source"]["media_type"],
|
|
||||||
"application/pdf"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use super::shared::{build_generated_tool_call_id, parse_openai_function_arguments};
|
|
||||||
|
|
||||||
pub fn convert_openai_chat_response_to_gemini_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let choices = body.get("choices")?.as_array()?;
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
for choice in choices {
|
|
||||||
let choice = choice.as_object()?;
|
|
||||||
let message = choice.get("message")?.as_object()?;
|
|
||||||
let mut parts = extract_openai_reasoning_to_gemini_parts(message);
|
|
||||||
parts.extend(convert_openai_assistant_content_to_gemini_parts(
|
|
||||||
message.get("content"),
|
|
||||||
)?);
|
|
||||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
for (index, tool_call) in tool_call_values.iter().enumerate() {
|
|
||||||
let tool_call = tool_call.as_object()?;
|
|
||||||
let function = tool_call.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let call_id = tool_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
parts.push(json!({
|
|
||||||
"functionCall": {
|
|
||||||
"id": call_id,
|
|
||||||
"name": tool_name,
|
|
||||||
"args": parse_openai_function_arguments(function.get("arguments"))?,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if parts.is_empty() {
|
|
||||||
parts.push(json!({ "text": "" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut finish_reason = match choice.get("finish_reason").and_then(Value::as_str) {
|
|
||||||
Some("stop") | None => "STOP",
|
|
||||||
Some("length") => "MAX_TOKENS",
|
|
||||||
Some("content_filter") => "SAFETY",
|
|
||||||
Some("tool_calls") | Some("function_call") => "STOP",
|
|
||||||
Some(other) => other,
|
|
||||||
};
|
|
||||||
if parts.iter().any(|part| part.get("functionCall").is_some()) {
|
|
||||||
finish_reason = "STOP";
|
|
||||||
}
|
|
||||||
candidates.push(json!({
|
|
||||||
"content": {
|
|
||||||
"role": "model",
|
|
||||||
"parts": parts,
|
|
||||||
},
|
|
||||||
"finishReason": finish_reason,
|
|
||||||
"index": choice.get("index").and_then(Value::as_u64).unwrap_or(0),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let reasoning_tokens = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens_details"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|details| details.get("reasoning_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let visible_completion_tokens = completion_tokens.saturating_sub(reasoning_tokens);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("total_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + completion_tokens);
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let response_id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("resp-local-finalize");
|
|
||||||
|
|
||||||
Some(json!({
|
|
||||||
"responseId": response_id,
|
|
||||||
"modelVersion": model,
|
|
||||||
"candidates": candidates,
|
|
||||||
"usageMetadata": {
|
|
||||||
"promptTokenCount": prompt_tokens,
|
|
||||||
"candidatesTokenCount": visible_completion_tokens,
|
|
||||||
"thoughtsTokenCount": reasoning_tokens,
|
|
||||||
"totalTokenCount": total_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_reasoning_to_gemini_parts(
|
|
||||||
message: &serde_json::Map<String, Value>,
|
|
||||||
) -> Vec<Value> {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
if let Some(reasoning_parts) = message.get("reasoning_parts").and_then(Value::as_array) {
|
|
||||||
for reasoning_part in reasoning_parts {
|
|
||||||
let Some(reasoning_object) = reasoning_part.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if reasoning_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value != "thinking")
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let thinking = reasoning_object
|
|
||||||
.get("thinking")
|
|
||||||
.or_else(|| reasoning_object.get("text"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if thinking.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut part = serde_json::Map::new();
|
|
||||||
part.insert("text".to_string(), Value::String(thinking.to_string()));
|
|
||||||
part.insert("thought".to_string(), Value::Bool(true));
|
|
||||||
if let Some(signature) = reasoning_object
|
|
||||||
.get("signature")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
part.insert(
|
|
||||||
"thoughtSignature".to_string(),
|
|
||||||
Value::String(signature.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
parts.push(Value::Object(part));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !parts.is_empty() {
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
if let Some(reasoning_content) = message
|
|
||||||
.get("reasoning_content")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
parts.push(json!({
|
|
||||||
"text": reasoning_content,
|
|
||||||
"thought": true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
parts
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_openai_assistant_content_to_gemini_parts(content: Option<&Value>) -> Option<Vec<Value>> {
|
|
||||||
match content {
|
|
||||||
None | Some(Value::Null) => Some(Vec::new()),
|
|
||||||
Some(Value::String(text)) => {
|
|
||||||
if text.trim().is_empty() {
|
|
||||||
Some(Vec::new())
|
|
||||||
} else {
|
|
||||||
Some(vec![json!({ "text": text })])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Array(parts)) => {
|
|
||||||
let mut converted = Vec::new();
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
let part_type = part
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match part_type.as_str() {
|
|
||||||
"text" | "output_text" => {
|
|
||||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
converted.push(json!({ "text": text }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"image_url" | "output_image" => {
|
|
||||||
let image_url = part
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(|value| {
|
|
||||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
|
||||||
value
|
|
||||||
.as_object()
|
|
||||||
.and_then(|object| object.get("url"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
part.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})?;
|
|
||||||
if let Some((mime_type, data)) = parse_data_url(image_url.as_str()) {
|
|
||||||
converted.push(json!({
|
|
||||||
"inlineData": {
|
|
||||||
"mimeType": mime_type,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
converted.push(json!({
|
|
||||||
"fileData": {
|
|
||||||
"fileUri": image_url,
|
|
||||||
"mimeType": guess_media_type_from_reference(image_url.as_str(), "image/jpeg"),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"file" | "input_file" => {
|
|
||||||
let file_object =
|
|
||||||
part.get("file").and_then(Value::as_object).unwrap_or(part);
|
|
||||||
if let Some(file_data) =
|
|
||||||
file_object.get("file_data").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
if let Some((mime_type, data)) = parse_data_url(file_data) {
|
|
||||||
converted.push(json!({
|
|
||||||
"inlineData": {
|
|
||||||
"mimeType": mime_type,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if let Some(file_id) = file_object
|
|
||||||
.get("file_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
converted.push(json!({
|
|
||||||
"text": format!("[File: {file_id}]"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"input_audio" => {
|
|
||||||
let audio_object = part
|
|
||||||
.get("input_audio")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part);
|
|
||||||
let data = audio_object
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let format = audio_object
|
|
||||||
.get("format")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
if let (Some(data), Some(format)) = (data, format) {
|
|
||||||
converted.push(json!({
|
|
||||||
"inlineData": {
|
|
||||||
"mimeType": format!("audio/{format}"),
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(converted)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
|
||||||
let rest = value.strip_prefix("data:")?;
|
|
||||||
let (meta, data) = rest.split_once(",")?;
|
|
||||||
let mime_type = meta.strip_suffix(";base64")?;
|
|
||||||
if mime_type.trim().is_empty() || data.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some((mime_type.to_string(), data.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn guess_media_type_from_reference(reference: &str, default_mime: &str) -> String {
|
|
||||||
let normalized = reference
|
|
||||||
.split('?')
|
|
||||||
.next()
|
|
||||||
.unwrap_or(reference)
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if normalized.ends_with(".png") {
|
|
||||||
"image/png".to_string()
|
|
||||||
} else if normalized.ends_with(".gif") {
|
|
||||||
"image/gif".to_string()
|
|
||||||
} else if normalized.ends_with(".webp") {
|
|
||||||
"image/webp".to_string()
|
|
||||||
} else if normalized.ends_with(".jpg") || normalized.ends_with(".jpeg") {
|
|
||||||
"image/jpeg".to_string()
|
|
||||||
} else if normalized.ends_with(".pdf") {
|
|
||||||
"application/pdf".to_string()
|
|
||||||
} else {
|
|
||||||
default_mime.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_openai_chat_response_to_gemini_chat;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_multiple_openai_choices_and_reasoning_tokens_for_gemini() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "chatcmpl_123",
|
|
||||||
"model": "gpt-5.4",
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "hello",
|
|
||||||
"reasoning_content": "step by step",
|
|
||||||
"reasoning_parts": [
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "step by step",
|
|
||||||
"signature": "sig_123"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": null,
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": "call_1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "lookup",
|
|
||||||
"arguments": "{\"city\":\"Shanghai\"}"
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"finish_reason": "tool_calls"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 10,
|
|
||||||
"completion_tokens": 7,
|
|
||||||
"total_tokens": 17,
|
|
||||||
"completion_tokens_details": {
|
|
||||||
"reasoning_tokens": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_chat_response_to_gemini_chat(&response, &json!({}))
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"]
|
|
||||||
.as_array()
|
|
||||||
.expect("candidates")
|
|
||||||
.len(),
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][0]["thought"],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][0]["thoughtSignature"],
|
|
||||||
"sig_123"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][1]["content"]["parts"][0]["functionCall"]["name"],
|
|
||||||
"lookup"
|
|
||||||
);
|
|
||||||
assert_eq!(converted["usageMetadata"]["candidatesTokenCount"], 5);
|
|
||||||
assert_eq!(converted["usageMetadata"]["thoughtsTokenCount"], 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_multimodal_openai_content_in_gemini_response() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "chatcmpl_mm_123",
|
|
||||||
"model": "gpt-5.4",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"reasoning_content": "step by step",
|
|
||||||
"content": [
|
|
||||||
{ "type": "text", "text": "Attached." },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": "data:image/png;base64,iVBORw0KGgo="
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": "SUQz",
|
|
||||||
"format": "mp3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": { "file_id": "file_123" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 4,
|
|
||||||
"completion_tokens": 3,
|
|
||||||
"completion_tokens_details": { "reasoning_tokens": 1 },
|
|
||||||
"total_tokens": 7
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_chat_response_to_gemini_chat(&response, &json!({}))
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][0]["thought"],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][1],
|
|
||||||
json!({ "text": "Attached." })
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][2],
|
|
||||||
json!({
|
|
||||||
"inlineData": {
|
|
||||||
"mimeType": "image/png",
|
|
||||||
"data": "iVBORw0KGgo="
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][3],
|
|
||||||
json!({
|
|
||||||
"inlineData": {
|
|
||||||
"mimeType": "application/pdf",
|
|
||||||
"data": "JVBERi0x"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][4],
|
|
||||||
json!({
|
|
||||||
"inlineData": {
|
|
||||||
"mimeType": "audio/mp3",
|
|
||||||
"data": "SUQz"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["candidates"][0]["content"]["parts"][5],
|
|
||||||
json!({ "text": "[File: file_123]" })
|
|
||||||
);
|
|
||||||
assert_eq!(converted["usageMetadata"]["candidatesTokenCount"], 2);
|
|
||||||
assert_eq!(converted["usageMetadata"]["thoughtsTokenCount"], 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mod claude_chat;
|
|
||||||
mod gemini_chat;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub use claude_chat::convert_openai_chat_response_to_claude_chat;
|
|
||||||
pub use gemini_chat::convert_openai_chat_response_to_gemini_chat;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
|
|
||||||
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
|
|
||||||
Value::Object(object) => Some(Value::Object(object)),
|
|
||||||
Value::String(text) => {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
Some(Value::Object(Map::new()))
|
|
||||||
} else {
|
|
||||||
match serde_json::from_str::<Value>(trimmed) {
|
|
||||||
Ok(Value::Object(object)) => Some(Value::Object(object)),
|
|
||||||
Ok(other) => Some(json!({ "raw": other })),
|
|
||||||
Err(_) => Some(json!({ "raw": text })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => Some(json!({ "raw": other })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
|
|
||||||
format!("call_auto_{index}")
|
|
||||||
}
|
|
||||||
@@ -3,19 +3,301 @@
|
|||||||
//! New response routing should use the registry so every conversion passes
|
//! New response routing should use the registry so every conversion passes
|
||||||
//! through the typed canonical IR.
|
//! through the typed canonical IR.
|
||||||
|
|
||||||
pub mod from_openai_chat;
|
//! Legacy response conversion function names.
|
||||||
pub mod openai_responses;
|
//!
|
||||||
pub mod to_openai_chat;
|
//! This module is intentionally a compatibility facade. Real wire-format
|
||||||
|
//! parsing and emitting lives under `formats::<format>::response`, and all
|
||||||
|
//! conversion goes through the registry's canonical IR path.
|
||||||
|
|
||||||
pub use from_openai_chat::{
|
use serde_json::{json, Value};
|
||||||
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
|
|
||||||
};
|
use crate::{context::FormatContext, registry};
|
||||||
pub use openai_responses::{
|
|
||||||
build_openai_responses_response, build_openai_responses_response_with_content,
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
build_openai_responses_response_with_reasoning, convert_claude_response_to_openai_responses,
|
pub struct OpenAiResponsesResponseUsage {
|
||||||
convert_gemini_response_to_openai_responses, convert_openai_chat_response_to_openai_responses,
|
pub prompt_tokens: u64,
|
||||||
convert_openai_responses_response_to_openai_chat, OpenAiResponsesResponseUsage,
|
pub output_tokens: u64,
|
||||||
};
|
pub total_tokens: u64,
|
||||||
pub use to_openai_chat::{
|
}
|
||||||
convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
|
|
||||||
};
|
pub fn convert_claude_chat_response_to_openai_chat(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_response(
|
||||||
|
"claude:messages",
|
||||||
|
"openai:chat",
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_gemini_chat_response_to_openai_chat(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_response(
|
||||||
|
"gemini:generate_content",
|
||||||
|
"openai:chat",
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_openai_chat_response_to_claude_chat(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_response(
|
||||||
|
"openai:chat",
|
||||||
|
"claude:messages",
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_openai_chat_response_to_gemini_chat(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_response(
|
||||||
|
"openai:chat",
|
||||||
|
"gemini:generate_content",
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_openai_responses_response_to_openai_chat(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_response(
|
||||||
|
"openai:responses",
|
||||||
|
"openai:chat",
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_openai_chat_response_to_openai_responses(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
compact: bool,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let target_format = if compact {
|
||||||
|
"openai:responses:compact"
|
||||||
|
} else {
|
||||||
|
"openai:responses"
|
||||||
|
};
|
||||||
|
registry::convert_response(
|
||||||
|
"openai:chat",
|
||||||
|
target_format,
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_claude_response_to_openai_responses(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_response(
|
||||||
|
"claude:messages",
|
||||||
|
"openai:responses",
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert_gemini_response_to_openai_responses(
|
||||||
|
body_json: &Value,
|
||||||
|
report_context: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
registry::convert_response(
|
||||||
|
"gemini:generate_content",
|
||||||
|
"openai:responses",
|
||||||
|
body_json,
|
||||||
|
&response_context(report_context),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_openai_responses_response(
|
||||||
|
response_id: &str,
|
||||||
|
model: &str,
|
||||||
|
text: &str,
|
||||||
|
function_calls: Vec<Value>,
|
||||||
|
prompt_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
total_tokens: u64,
|
||||||
|
) -> Value {
|
||||||
|
let content = if text.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![json!({
|
||||||
|
"type": "output_text",
|
||||||
|
"text": text,
|
||||||
|
"annotations": []
|
||||||
|
})]
|
||||||
|
};
|
||||||
|
build_openai_responses_response_with_content(
|
||||||
|
response_id,
|
||||||
|
model,
|
||||||
|
content,
|
||||||
|
Vec::new(),
|
||||||
|
function_calls,
|
||||||
|
OpenAiResponsesResponseUsage {
|
||||||
|
prompt_tokens,
|
||||||
|
output_tokens,
|
||||||
|
total_tokens,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_openai_responses_response_with_reasoning(
|
||||||
|
response_id: &str,
|
||||||
|
model: &str,
|
||||||
|
text: &str,
|
||||||
|
reasoning_summaries: Vec<String>,
|
||||||
|
function_calls: Vec<Value>,
|
||||||
|
usage: OpenAiResponsesResponseUsage,
|
||||||
|
) -> Value {
|
||||||
|
let content = if text.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![json!({
|
||||||
|
"type": "output_text",
|
||||||
|
"text": text,
|
||||||
|
"annotations": []
|
||||||
|
})]
|
||||||
|
};
|
||||||
|
build_openai_responses_response_with_content(
|
||||||
|
response_id,
|
||||||
|
model,
|
||||||
|
content,
|
||||||
|
reasoning_summaries,
|
||||||
|
function_calls,
|
||||||
|
usage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_openai_responses_response_with_content(
|
||||||
|
response_id: &str,
|
||||||
|
model: &str,
|
||||||
|
content: Vec<Value>,
|
||||||
|
reasoning_summaries: Vec<String>,
|
||||||
|
function_calls: Vec<Value>,
|
||||||
|
usage: OpenAiResponsesResponseUsage,
|
||||||
|
) -> Value {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
|
||||||
|
let trimmed = summary.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push(json!({
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": format!("{response_id}_rs_{index}"),
|
||||||
|
"status": "completed",
|
||||||
|
"summary": [{
|
||||||
|
"type": "summary_text",
|
||||||
|
"text": trimmed,
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if !content.is_empty() {
|
||||||
|
output.push(json!({
|
||||||
|
"type": "message",
|
||||||
|
"id": format!("{response_id}_msg"),
|
||||||
|
"role": "assistant",
|
||||||
|
"status": "completed",
|
||||||
|
"content": content
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
output.extend(function_calls);
|
||||||
|
json!({
|
||||||
|
"id": response_id,
|
||||||
|
"object": "response",
|
||||||
|
"status": "completed",
|
||||||
|
"model": model,
|
||||||
|
"output": output,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": usage.prompt_tokens,
|
||||||
|
"output_tokens": usage.output_tokens,
|
||||||
|
"total_tokens": usage.total_tokens,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_context(report_context: &Value) -> FormatContext {
|
||||||
|
let mut context = FormatContext::default().with_report_context(report_context.clone());
|
||||||
|
if let Some(model) = report_context
|
||||||
|
.get("mapped_model")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
context = context.with_mapped_model(model);
|
||||||
|
}
|
||||||
|
context
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
convert_claude_chat_response_to_openai_chat,
|
||||||
|
convert_openai_chat_response_to_openai_responses,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_response_facade_routes_through_registry() {
|
||||||
|
let body = json!({
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"model": "gpt-source",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "hello"},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = convert_openai_chat_response_to_openai_responses(&body, &json!({}), false)
|
||||||
|
.expect("responses response");
|
||||||
|
|
||||||
|
assert_eq!(converted["object"], "response");
|
||||||
|
assert_eq!(converted["output"][0]["type"], "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_response_facade_uses_report_context_model_fallback() {
|
||||||
|
let body = json!({
|
||||||
|
"id": "msg-test",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": "hello"}],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": {"input_tokens": 1, "output_tokens": 2}
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = convert_claude_chat_response_to_openai_chat(
|
||||||
|
&body,
|
||||||
|
&json!({"mapped_model": "gpt-target"}),
|
||||||
|
)
|
||||||
|
.expect("openai chat response");
|
||||||
|
|
||||||
|
assert_eq!(converted["model"], "gpt-target");
|
||||||
|
assert_eq!(converted["choices"][0]["message"]["content"], "hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,443 +0,0 @@
|
|||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use super::shared::{
|
|
||||||
build_openai_responses_response_with_content, canonicalize_tool_arguments,
|
|
||||||
OpenAiResponsesResponseUsage,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn convert_openai_chat_response_to_openai_responses(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
compact: bool,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let choices = body.get("choices")?.as_array()?;
|
|
||||||
let first_choice = choices.first()?.as_object()?;
|
|
||||||
let message = first_choice.get("message")?.as_object()?;
|
|
||||||
let mut message_content = Vec::new();
|
|
||||||
let mut reasoning_summaries = Vec::new();
|
|
||||||
let message_annotations = message.get("annotations").cloned();
|
|
||||||
match message.get("content") {
|
|
||||||
Some(Value::String(value)) => {
|
|
||||||
if !value.is_empty() {
|
|
||||||
let mut item = json!({
|
|
||||||
"type": "output_text",
|
|
||||||
"text": value,
|
|
||||||
"annotations": []
|
|
||||||
});
|
|
||||||
if let Some(annotations) = message_annotations.clone() {
|
|
||||||
item["annotations"] = annotations;
|
|
||||||
}
|
|
||||||
message_content.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Array(parts)) => {
|
|
||||||
let text_part_count = parts
|
|
||||||
.iter()
|
|
||||||
.filter_map(Value::as_object)
|
|
||||||
.filter(|part| {
|
|
||||||
matches!(
|
|
||||||
part.get("type").and_then(Value::as_str).unwrap_or_default(),
|
|
||||||
"text" | "output_text"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
for part in parts {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
let part_type = part
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if matches!(part_type.as_str(), "text" | "output_text") {
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
let mut item = json!({
|
|
||||||
"type": "output_text",
|
|
||||||
"text": piece,
|
|
||||||
"annotations": []
|
|
||||||
});
|
|
||||||
if text_part_count == 1 {
|
|
||||||
if let Some(annotations) = message_annotations.clone() {
|
|
||||||
item["annotations"] = annotations;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message_content.push(item);
|
|
||||||
}
|
|
||||||
} else if matches!(part_type.as_str(), "image_url" | "output_image") {
|
|
||||||
if let Some(image_url) = part
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(|value| {
|
|
||||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
|
||||||
value
|
|
||||||
.as_object()
|
|
||||||
.and_then(|object| object.get("url"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
part.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
let mut image_part = json!({
|
|
||||||
"type": "output_image",
|
|
||||||
"image_url": image_url,
|
|
||||||
});
|
|
||||||
if let Some(detail) =
|
|
||||||
part.get("detail").and_then(Value::as_str).or_else(|| {
|
|
||||||
part.get("image_url")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|image| image.get("detail"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
image_part["detail"] = Value::String(detail.to_string());
|
|
||||||
}
|
|
||||||
message_content.push(image_part);
|
|
||||||
}
|
|
||||||
} else if matches!(part_type.as_str(), "file" | "input_file") {
|
|
||||||
if let Some(file_part) = build_openai_responses_file_part(part) {
|
|
||||||
message_content.push(file_part);
|
|
||||||
}
|
|
||||||
} else if part_type == "input_audio" {
|
|
||||||
if let Some(audio_part) = build_openai_responses_input_audio_part(part) {
|
|
||||||
message_content.push(audio_part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Null) | None => {}
|
|
||||||
_ => return None,
|
|
||||||
}
|
|
||||||
if let Some(refusal) = message.get("refusal").and_then(Value::as_str) {
|
|
||||||
if !refusal.trim().is_empty() {
|
|
||||||
message_content.push(json!({
|
|
||||||
"type": "refusal",
|
|
||||||
"refusal": refusal,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(reasoning_content) = message.get("reasoning_content").and_then(Value::as_str) {
|
|
||||||
if !reasoning_content.trim().is_empty() {
|
|
||||||
reasoning_summaries.push(reasoning_content.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut function_calls = Vec::new();
|
|
||||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
|
||||||
for tool_call in tool_call_values {
|
|
||||||
let tool_call = tool_call.as_object()?;
|
|
||||||
let function = tool_call.get("function")?.as_object()?;
|
|
||||||
let tool_name = function
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
function_calls.push(json!({
|
|
||||||
"type": "function_call",
|
|
||||||
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
|
|
||||||
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let output_tokens = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("total_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + output_tokens);
|
|
||||||
let response_id = if compact {
|
|
||||||
body.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|value| value.replace("chatcmpl", "resp"))
|
|
||||||
.unwrap_or_else(|| "resp-local-finalize".to_string())
|
|
||||||
} else {
|
|
||||||
body.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|value| value.replace("chatcmpl", "resp"))
|
|
||||||
.unwrap_or_else(|| "resp-local-finalize".to_string())
|
|
||||||
};
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
let mut response = build_openai_responses_response_with_content(
|
|
||||||
&response_id,
|
|
||||||
model,
|
|
||||||
message_content,
|
|
||||||
reasoning_summaries,
|
|
||||||
function_calls,
|
|
||||||
OpenAiResponsesResponseUsage {
|
|
||||||
prompt_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(created) = body.get("created").and_then(Value::as_i64).or_else(|| {
|
|
||||||
body.get("created")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as i64)
|
|
||||||
}) {
|
|
||||||
response["created_at"] = Value::from(created);
|
|
||||||
}
|
|
||||||
if let Some(service_tier) = body.get("service_tier").cloned().or_else(|| {
|
|
||||||
report_context
|
|
||||||
.get("original_request_body")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|request| request.get("service_tier"))
|
|
||||||
.cloned()
|
|
||||||
}) {
|
|
||||||
response["service_tier"] = service_tier;
|
|
||||||
}
|
|
||||||
if let Some(request_object) = report_context
|
|
||||||
.get("original_request_body")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
for key in [
|
|
||||||
"instructions",
|
|
||||||
"max_output_tokens",
|
|
||||||
"parallel_tool_calls",
|
|
||||||
"previous_response_id",
|
|
||||||
"reasoning",
|
|
||||||
"store",
|
|
||||||
"temperature",
|
|
||||||
"text",
|
|
||||||
"tool_choice",
|
|
||||||
"tools",
|
|
||||||
"top_p",
|
|
||||||
"truncation",
|
|
||||||
"user",
|
|
||||||
"metadata",
|
|
||||||
] {
|
|
||||||
if let Some(value) = request_object.get(key) {
|
|
||||||
response[key] = value.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(prompt_details) = usage
|
|
||||||
.and_then(|value| value.get("prompt_tokens_details"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
response["usage"]["input_tokens_details"] = prompt_details;
|
|
||||||
}
|
|
||||||
if let Some(completion_details) = usage
|
|
||||||
.and_then(|value| value.get("completion_tokens_details"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
response["usage"]["output_tokens_details"] = completion_details;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_responses_file_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
|
|
||||||
let file_object = part.get("file").and_then(Value::as_object).unwrap_or(part);
|
|
||||||
let mut file = serde_json::Map::new();
|
|
||||||
for key in ["file_data", "file_id", "filename"] {
|
|
||||||
if let Some(value) = file_object
|
|
||||||
.get(key)
|
|
||||||
.cloned()
|
|
||||||
.filter(|value| !value.is_null())
|
|
||||||
{
|
|
||||||
file.insert(key.to_string(), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if file.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(json!({
|
|
||||||
"type": "file",
|
|
||||||
"file": Value::Object(file),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_openai_responses_input_audio_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
|
|
||||||
let audio_object = part
|
|
||||||
.get("input_audio")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part);
|
|
||||||
let data = audio_object
|
|
||||||
.get("data")
|
|
||||||
.cloned()
|
|
||||||
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
|
|
||||||
let format = audio_object
|
|
||||||
.get("format")
|
|
||||||
.cloned()
|
|
||||||
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": data,
|
|
||||||
"format": format,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_openai_chat_response_to_openai_responses;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_created_refusal_request_echo_and_usage_details_when_converting_to_responses() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "chatcmpl_123",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"created": 1741569952i64,
|
|
||||||
"model": "gpt-5",
|
|
||||||
"service_tier": "default",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Hello",
|
|
||||||
"refusal": "partial refusal",
|
|
||||||
"annotations": [{"type": "url_citation", "start_index": 0, "end_index": 5}]
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 19,
|
|
||||||
"completion_tokens": 10,
|
|
||||||
"total_tokens": 29,
|
|
||||||
"prompt_tokens_details": {"cached_tokens": 0},
|
|
||||||
"completion_tokens_details": {"reasoning_tokens": 0}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let report_context = json!({
|
|
||||||
"original_request_body": {
|
|
||||||
"instructions": "Be concise.",
|
|
||||||
"max_output_tokens": 32,
|
|
||||||
"parallel_tool_calls": true,
|
|
||||||
"reasoning": {"effort": "medium"},
|
|
||||||
"store": true,
|
|
||||||
"temperature": 1.0,
|
|
||||||
"text": {"format": {"type": "text"}},
|
|
||||||
"tool_choice": "auto",
|
|
||||||
"tools": [],
|
|
||||||
"top_p": 1.0,
|
|
||||||
"truncation": "disabled",
|
|
||||||
"user": null,
|
|
||||||
"metadata": {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted =
|
|
||||||
convert_openai_chat_response_to_openai_responses(&response, &report_context, false)
|
|
||||||
.expect("chat response should convert to responses");
|
|
||||||
|
|
||||||
assert_eq!(converted["created_at"], 1741569952i64);
|
|
||||||
assert_eq!(converted["service_tier"], "default");
|
|
||||||
assert_eq!(converted["instructions"], "Be concise.");
|
|
||||||
assert_eq!(converted["max_output_tokens"], 32);
|
|
||||||
assert_eq!(converted["parallel_tool_calls"], true);
|
|
||||||
assert_eq!(converted["text"], json!({"format": {"type": "text"}}));
|
|
||||||
assert_eq!(converted["top_p"], 1.0);
|
|
||||||
assert_eq!(
|
|
||||||
converted["output"][0]["content"],
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Hello",
|
|
||||||
"annotations": [{"type": "url_citation", "start_index": 0, "end_index": 5}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "refusal",
|
|
||||||
"refusal": "partial refusal"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["usage"]["input_tokens_details"],
|
|
||||||
json!({"cached_tokens": 0})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["usage"]["output_tokens_details"],
|
|
||||||
json!({"reasoning_tokens": 0})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_file_and_audio_parts_when_converting_to_responses() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "chatcmpl_mm_123",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": "gpt-5",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{ "type": "text", "text": "Attached." },
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x",
|
|
||||||
"filename": "report.pdf"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": "SUQz",
|
|
||||||
"format": "mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 4,
|
|
||||||
"completion_tokens": 2,
|
|
||||||
"total_tokens": 6
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted =
|
|
||||||
convert_openai_chat_response_to_openai_responses(&response, &json!({}), false)
|
|
||||||
.expect("chat response should convert to responses");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["output"][0]["content"],
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Attached.",
|
|
||||||
"annotations": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x",
|
|
||||||
"filename": "report.pdf"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": "SUQz",
|
|
||||||
"format": "mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
mod from_chat;
|
|
||||||
mod shared;
|
|
||||||
mod to_chat;
|
|
||||||
|
|
||||||
pub use from_chat::convert_openai_chat_response_to_openai_responses;
|
|
||||||
pub use shared::{
|
|
||||||
build_openai_responses_response, build_openai_responses_response_with_content,
|
|
||||||
build_openai_responses_response_with_reasoning, OpenAiResponsesResponseUsage,
|
|
||||||
};
|
|
||||||
pub use to_chat::convert_openai_responses_response_to_openai_chat;
|
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub fn convert_claude_response_to_openai_responses(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let chat_response = super::to_openai_chat::convert_claude_chat_response_to_openai_chat(
|
|
||||||
body_json,
|
|
||||||
report_context,
|
|
||||||
)?;
|
|
||||||
convert_openai_chat_response_to_openai_responses(&chat_response, report_context, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn convert_gemini_response_to_openai_responses(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let chat_response = super::to_openai_chat::convert_gemini_chat_response_to_openai_chat(
|
|
||||||
body_json,
|
|
||||||
report_context,
|
|
||||||
)?;
|
|
||||||
convert_openai_chat_response_to_openai_responses(&chat_response, report_context, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
convert_openai_chat_response_to_openai_responses,
|
|
||||||
convert_openai_responses_response_to_openai_chat,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_chat_response_to_responses_wire_shape() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "chatcmpl_1",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": "gpt-5",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {"role": "assistant", "content": "done"},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}],
|
|
||||||
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted =
|
|
||||||
convert_openai_chat_response_to_openai_responses(&response, &json!({}), false)
|
|
||||||
.expect("responses response");
|
|
||||||
|
|
||||||
assert_eq!(converted["object"], "response");
|
|
||||||
assert_eq!(converted["output"][0]["content"][0]["text"], "done");
|
|
||||||
assert_eq!(converted["usage"]["input_tokens"], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_responses_wire_shape_to_chat_response() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "resp_1",
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"model": "gpt-5",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "output_text", "text": "done", "annotations": []}]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_responses_response_to_openai_chat(&response, &json!({}))
|
|
||||||
.expect("chat response");
|
|
||||||
|
|
||||||
assert_eq!(converted["object"], "chat.completion");
|
|
||||||
assert_eq!(converted["choices"][0]["message"]["content"], "done");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct OpenAiResponsesResponseUsage {
|
|
||||||
pub prompt_tokens: u64,
|
|
||||||
pub output_tokens: u64,
|
|
||||||
pub total_tokens: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_openai_responses_response(
|
|
||||||
response_id: &str,
|
|
||||||
model: &str,
|
|
||||||
text: &str,
|
|
||||||
function_calls: Vec<Value>,
|
|
||||||
prompt_tokens: u64,
|
|
||||||
output_tokens: u64,
|
|
||||||
total_tokens: u64,
|
|
||||||
) -> Value {
|
|
||||||
let content = if text.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
vec![json!({
|
|
||||||
"type": "output_text",
|
|
||||||
"text": text,
|
|
||||||
"annotations": []
|
|
||||||
})]
|
|
||||||
};
|
|
||||||
build_openai_responses_response_with_content(
|
|
||||||
response_id,
|
|
||||||
model,
|
|
||||||
content,
|
|
||||||
Vec::new(),
|
|
||||||
function_calls,
|
|
||||||
OpenAiResponsesResponseUsage {
|
|
||||||
prompt_tokens,
|
|
||||||
output_tokens,
|
|
||||||
total_tokens,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_openai_responses_response_with_reasoning(
|
|
||||||
response_id: &str,
|
|
||||||
model: &str,
|
|
||||||
text: &str,
|
|
||||||
reasoning_summaries: Vec<String>,
|
|
||||||
function_calls: Vec<Value>,
|
|
||||||
usage: OpenAiResponsesResponseUsage,
|
|
||||||
) -> Value {
|
|
||||||
let content = if text.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
vec![json!({
|
|
||||||
"type": "output_text",
|
|
||||||
"text": text,
|
|
||||||
"annotations": []
|
|
||||||
})]
|
|
||||||
};
|
|
||||||
build_openai_responses_response_with_content(
|
|
||||||
response_id,
|
|
||||||
model,
|
|
||||||
content,
|
|
||||||
reasoning_summaries,
|
|
||||||
function_calls,
|
|
||||||
usage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_openai_responses_response_with_content(
|
|
||||||
response_id: &str,
|
|
||||||
model: &str,
|
|
||||||
content: Vec<Value>,
|
|
||||||
reasoning_summaries: Vec<String>,
|
|
||||||
function_calls: Vec<Value>,
|
|
||||||
usage: OpenAiResponsesResponseUsage,
|
|
||||||
) -> Value {
|
|
||||||
let mut output = Vec::new();
|
|
||||||
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
|
|
||||||
let trimmed = summary.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
output.push(json!({
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": format!("{response_id}_rs_{index}"),
|
|
||||||
"status": "completed",
|
|
||||||
"summary": [{
|
|
||||||
"type": "summary_text",
|
|
||||||
"text": trimmed,
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if !content.is_empty() {
|
|
||||||
output.push(json!({
|
|
||||||
"type": "message",
|
|
||||||
"id": format!("{response_id}_msg"),
|
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
|
||||||
"content": content
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
output.extend(function_calls);
|
|
||||||
json!({
|
|
||||||
"id": response_id,
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"model": model,
|
|
||||||
"output": output,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": usage.prompt_tokens,
|
|
||||||
"output_tokens": usage.output_tokens,
|
|
||||||
"total_tokens": usage.total_tokens,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
|
|
||||||
format!("call_auto_{index}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
|
||||||
match value {
|
|
||||||
Some(Value::String(text)) => text,
|
|
||||||
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
|
||||||
None => "{}".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,494 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
|
||||||
|
|
||||||
pub fn convert_openai_responses_response_to_openai_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut content_parts = Vec::new();
|
|
||||||
let mut reasoning_content = String::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
let mut annotations = Vec::new();
|
|
||||||
let mut refusal = Vec::new();
|
|
||||||
let mut has_non_text_content = false;
|
|
||||||
|
|
||||||
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
|
|
||||||
for (index, item) in output_items.iter().enumerate() {
|
|
||||||
let item_object = item.as_object()?;
|
|
||||||
let item_type = item_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
match item_type.as_str() {
|
|
||||||
"message" => {
|
|
||||||
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
|
|
||||||
for part in content {
|
|
||||||
let part_object = part.as_object()?;
|
|
||||||
let part_type = part_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
if matches!(part_type.as_str(), "output_text" | "text") {
|
|
||||||
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
let annotation_offset = text.chars().count() as i64;
|
|
||||||
if let Some(raw_annotations) =
|
|
||||||
part_object.get("annotations").and_then(Value::as_array)
|
|
||||||
{
|
|
||||||
annotations.extend(raw_annotations.iter().map(
|
|
||||||
|annotation| {
|
|
||||||
offset_annotation_indices(
|
|
||||||
annotation,
|
|
||||||
annotation_offset,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
text.push_str(piece);
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": piece,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if part_type == "refusal" {
|
|
||||||
if let Some(piece) =
|
|
||||||
part_object.get("refusal").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
if !piece.trim().is_empty() {
|
|
||||||
refusal.push(piece.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if matches!(part_type.as_str(), "output_image" | "image_url") {
|
|
||||||
if let Some((image_url, detail)) =
|
|
||||||
extract_openai_response_image(part_object)
|
|
||||||
{
|
|
||||||
let mut image = Map::new();
|
|
||||||
image.insert("url".to_string(), Value::String(image_url));
|
|
||||||
if let Some(detail) = detail {
|
|
||||||
image.insert("detail".to_string(), Value::String(detail));
|
|
||||||
}
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": image,
|
|
||||||
}));
|
|
||||||
has_non_text_content = true;
|
|
||||||
}
|
|
||||||
} else if part_type == "file" {
|
|
||||||
if let Some(file_part) = extract_openai_response_file(part_object) {
|
|
||||||
content_parts.push(file_part);
|
|
||||||
has_non_text_content = true;
|
|
||||||
}
|
|
||||||
} else if part_type == "input_audio" {
|
|
||||||
if let Some(audio_part) =
|
|
||||||
extract_openai_response_input_audio(part_object)
|
|
||||||
{
|
|
||||||
content_parts.push(audio_part);
|
|
||||||
has_non_text_content = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"reasoning" => {
|
|
||||||
if let Some(summary_items) =
|
|
||||||
item_object.get("summary").and_then(Value::as_array)
|
|
||||||
{
|
|
||||||
for summary in summary_items {
|
|
||||||
let summary_object = summary.as_object()?;
|
|
||||||
if summary_object
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|value| value == "summary_text")
|
|
||||||
{
|
|
||||||
if let Some(piece) =
|
|
||||||
summary_object.get("text").and_then(Value::as_str)
|
|
||||||
{
|
|
||||||
reasoning_content.push_str(piece);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"function_call" => {
|
|
||||||
let tool_name = item_object
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let tool_id = item_object
|
|
||||||
.get("call_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
item_object
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
})
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"output_text" | "text" => {
|
|
||||||
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": piece,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"output_image" | "image_url" => {
|
|
||||||
if let Some((image_url, detail)) = extract_openai_response_image(item_object) {
|
|
||||||
let mut image = Map::new();
|
|
||||||
image.insert("url".to_string(), Value::String(image_url));
|
|
||||||
if let Some(detail) = detail {
|
|
||||||
image.insert("detail".to_string(), Value::String(detail));
|
|
||||||
}
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": image,
|
|
||||||
}));
|
|
||||||
has_non_text_content = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let finish_reason = if tool_calls.is_empty() {
|
|
||||||
Some("stop")
|
|
||||||
} else {
|
|
||||||
Some("tool_calls")
|
|
||||||
};
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("chatcmpl-local-openai-cli");
|
|
||||||
let created = body.get("created_at").and_then(Value::as_i64).or_else(|| {
|
|
||||||
body.get("created_at")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|value| value as i64)
|
|
||||||
});
|
|
||||||
let service_tier = body.get("service_tier").cloned().or_else(|| {
|
|
||||||
report_context
|
|
||||||
.get("original_request_body")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|request| request.get("service_tier"))
|
|
||||||
.cloned()
|
|
||||||
});
|
|
||||||
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("input_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("output_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("total_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + completion_tokens);
|
|
||||||
|
|
||||||
let mut message = Map::new();
|
|
||||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
if content_parts.is_empty() && !tool_calls.is_empty() {
|
|
||||||
message.insert("content".to_string(), Value::Null);
|
|
||||||
} else if has_non_text_content {
|
|
||||||
message.insert("content".to_string(), Value::Array(content_parts));
|
|
||||||
} else {
|
|
||||||
message.insert("content".to_string(), Value::String(text));
|
|
||||||
}
|
|
||||||
if !reasoning_content.trim().is_empty() {
|
|
||||||
message.insert(
|
|
||||||
"reasoning_content".to_string(),
|
|
||||||
Value::String(reasoning_content),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !refusal.is_empty() {
|
|
||||||
message.insert("refusal".to_string(), Value::String(refusal.join("\n")));
|
|
||||||
}
|
|
||||||
if !annotations.is_empty() {
|
|
||||||
message.insert("annotations".to_string(), Value::Array(annotations));
|
|
||||||
}
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut response = json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": Value::Object(message),
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": prompt_tokens,
|
|
||||||
"completion_tokens": completion_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if let Some(created) = created {
|
|
||||||
response["created"] = Value::from(created);
|
|
||||||
}
|
|
||||||
if let Some(service_tier) = service_tier {
|
|
||||||
response["service_tier"] = service_tier;
|
|
||||||
}
|
|
||||||
if let Some(input_details) = usage
|
|
||||||
.and_then(|value| value.get("input_tokens_details"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
response["usage"]["prompt_tokens_details"] = input_details;
|
|
||||||
}
|
|
||||||
if let Some(output_details) = usage
|
|
||||||
.and_then(|value| value.get("output_tokens_details"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
response["usage"]["completion_tokens_details"] = output_details;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_response_image(
|
|
||||||
part_object: &Map<String, Value>,
|
|
||||||
) -> Option<(String, Option<String>)> {
|
|
||||||
let image_url = part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(|value| {
|
|
||||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
|
||||||
value
|
|
||||||
.as_object()
|
|
||||||
.and_then(|object| object.get("url"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
part_object
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
})?;
|
|
||||||
let detail = part_object
|
|
||||||
.get("detail")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| {
|
|
||||||
part_object
|
|
||||||
.get("image_url")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|image| image.get("detail"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
});
|
|
||||||
Some((image_url, detail))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_response_file(part_object: &Map<String, Value>) -> Option<Value> {
|
|
||||||
let file_object = part_object
|
|
||||||
.get("file")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part_object);
|
|
||||||
let mut file = Map::new();
|
|
||||||
for key in ["file_data", "file_id", "filename"] {
|
|
||||||
if let Some(value) = file_object
|
|
||||||
.get(key)
|
|
||||||
.cloned()
|
|
||||||
.filter(|value| !value.is_null())
|
|
||||||
{
|
|
||||||
file.insert(key.to_string(), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if file.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(json!({
|
|
||||||
"type": "file",
|
|
||||||
"file": Value::Object(file),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_openai_response_input_audio(part_object: &Map<String, Value>) -> Option<Value> {
|
|
||||||
let audio_object = part_object
|
|
||||||
.get("input_audio")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.unwrap_or(part_object);
|
|
||||||
let data = audio_object
|
|
||||||
.get("data")
|
|
||||||
.cloned()
|
|
||||||
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
|
|
||||||
let format = audio_object
|
|
||||||
.get("format")
|
|
||||||
.cloned()
|
|
||||||
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": data,
|
|
||||||
"format": format,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn offset_annotation_indices(annotation: &Value, offset: i64) -> Value {
|
|
||||||
let Some(object) = annotation.as_object() else {
|
|
||||||
return annotation.clone();
|
|
||||||
};
|
|
||||||
let mut adjusted = object.clone();
|
|
||||||
for key in [
|
|
||||||
"start_index",
|
|
||||||
"end_index",
|
|
||||||
"start_char",
|
|
||||||
"end_char",
|
|
||||||
"index",
|
|
||||||
] {
|
|
||||||
if let Some(value) = adjusted.get(key).and_then(Value::as_i64) {
|
|
||||||
adjusted.insert(key.to_string(), Value::from(value + offset));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::Object(adjusted)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_openai_responses_response_to_openai_chat;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_created_refusal_annotations_and_usage_details_when_converting_to_chat() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "resp_123",
|
|
||||||
"object": "response",
|
|
||||||
"created_at": 1741476542i64,
|
|
||||||
"model": "gpt-5",
|
|
||||||
"service_tier": "flex",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Hello",
|
|
||||||
"annotations": [{"type": "file_citation", "start_index": 0, "end_index": 5}]
|
|
||||||
},
|
|
||||||
{"type": "refusal", "refusal": "partial refusal"}
|
|
||||||
]
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 10,
|
|
||||||
"input_tokens_details": {"cached_tokens": 2},
|
|
||||||
"output_tokens": 4,
|
|
||||||
"output_tokens_details": {"reasoning_tokens": 1},
|
|
||||||
"total_tokens": 14
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_responses_response_to_openai_chat(&response, &json!({}))
|
|
||||||
.expect("responses response should convert to chat");
|
|
||||||
|
|
||||||
assert_eq!(converted["created"], 1741476542i64);
|
|
||||||
assert_eq!(converted["service_tier"], "flex");
|
|
||||||
assert_eq!(converted["choices"][0]["message"]["content"], "Hello");
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["refusal"],
|
|
||||||
"partial refusal"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["annotations"],
|
|
||||||
json!([{"type": "file_citation", "start_index": 0, "end_index": 5}])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["usage"]["prompt_tokens_details"],
|
|
||||||
json!({"cached_tokens": 2})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["usage"]["completion_tokens_details"],
|
|
||||||
json!({"reasoning_tokens": 1})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_file_and_audio_parts_when_converting_to_chat() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "resp_mm_123",
|
|
||||||
"object": "response",
|
|
||||||
"model": "gpt-5",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{ "type": "output_text", "text": "Attached." },
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x",
|
|
||||||
"filename": "report.pdf"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": "SUQz",
|
|
||||||
"format": "mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 4,
|
|
||||||
"output_tokens": 2,
|
|
||||||
"total_tokens": 6
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_openai_responses_response_to_openai_chat(&response, &json!({}))
|
|
||||||
.expect("responses response should convert to chat");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["content"],
|
|
||||||
json!([
|
|
||||||
{ "type": "text", "text": "Attached." },
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x",
|
|
||||||
"filename": "report.pdf"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": "SUQz",
|
|
||||||
"format": "mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
|
||||||
|
|
||||||
pub fn convert_claude_chat_response_to_openai_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let content = body.get("content")?.as_array()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut content_parts = Vec::new();
|
|
||||||
let mut reasoning_content = String::new();
|
|
||||||
let mut reasoning_parts = Vec::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
let mut has_non_text_content = false;
|
|
||||||
for (index, block) in content.iter().enumerate() {
|
|
||||||
let block = block.as_object()?;
|
|
||||||
match block.get("type")?.as_str()? {
|
|
||||||
"text" => {
|
|
||||||
let piece = block.get("text")?.as_str()?;
|
|
||||||
push_openai_text_part(&mut text, &mut content_parts, piece);
|
|
||||||
}
|
|
||||||
"thinking" => {
|
|
||||||
let piece = block
|
|
||||||
.get("thinking")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| block.get("text").and_then(Value::as_str))
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !piece.is_empty() {
|
|
||||||
reasoning_content.push_str(piece);
|
|
||||||
}
|
|
||||||
let mut reasoning_part = Map::new();
|
|
||||||
reasoning_part.insert("type".to_string(), Value::String("thinking".to_string()));
|
|
||||||
reasoning_part.insert("thinking".to_string(), Value::String(piece.to_string()));
|
|
||||||
if let Some(signature) = block
|
|
||||||
.get("signature")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
reasoning_part.insert(
|
|
||||||
"signature".to_string(),
|
|
||||||
Value::String(signature.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
reasoning_parts.push(Value::Object(reasoning_part));
|
|
||||||
}
|
|
||||||
"redacted_thinking" => {
|
|
||||||
let data = block
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !data.is_empty() {
|
|
||||||
reasoning_parts.push(json!({
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tool_use" => {
|
|
||||||
let tool_name = block.get("name")?.as_str()?;
|
|
||||||
let tool_id = block
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"image" => {
|
|
||||||
content_parts.push(convert_claude_image_block_to_openai_part(block)?);
|
|
||||||
has_non_text_content = true;
|
|
||||||
}
|
|
||||||
"document" => {
|
|
||||||
let part = convert_claude_document_block_to_openai_part(block)?;
|
|
||||||
if part.get("type").and_then(Value::as_str) == Some("text") {
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
push_openai_text_part(&mut text, &mut content_parts, piece);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content_parts.push(part);
|
|
||||||
has_non_text_content = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut finish_reason = match body.get("stop_reason").and_then(Value::as_str) {
|
|
||||||
Some("end_turn") | Some("stop_sequence") => Some("stop"),
|
|
||||||
Some("max_tokens") => Some("length"),
|
|
||||||
Some("tool_use") => Some("tool_calls"),
|
|
||||||
Some(other) if !other.is_empty() => Some(other),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
|
|
||||||
finish_reason = Some("tool_calls");
|
|
||||||
}
|
|
||||||
let usage = body.get("usage").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("input_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("output_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_tokens = prompt_tokens + completion_tokens;
|
|
||||||
let model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("chatcmpl-local-finalize");
|
|
||||||
let message_content = if content_parts.is_empty() && !tool_calls.is_empty() {
|
|
||||||
Value::Null
|
|
||||||
} else if has_non_text_content {
|
|
||||||
Value::Array(content_parts)
|
|
||||||
} else {
|
|
||||||
Value::String(text)
|
|
||||||
};
|
|
||||||
let mut message = Map::new();
|
|
||||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
message.insert("content".to_string(), message_content);
|
|
||||||
if !reasoning_content.trim().is_empty() {
|
|
||||||
message.insert(
|
|
||||||
"reasoning_content".to_string(),
|
|
||||||
Value::String(reasoning_content),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !reasoning_parts.is_empty() {
|
|
||||||
message.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
|
|
||||||
}
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
Some(json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": model,
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": Value::Object(message),
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": prompt_tokens,
|
|
||||||
"completion_tokens": completion_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.map(|mut response| {
|
|
||||||
if let Some(service_tier) = report_context
|
|
||||||
.get("original_request_body")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|request| request.get("service_tier"))
|
|
||||||
.cloned()
|
|
||||||
{
|
|
||||||
response["service_tier"] = service_tier;
|
|
||||||
}
|
|
||||||
let mut prompt_details = Map::new();
|
|
||||||
if let Some(cached_tokens) = usage
|
|
||||||
.and_then(|value| value.get("cache_read_input_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
{
|
|
||||||
prompt_details.insert("cached_tokens".to_string(), Value::from(cached_tokens));
|
|
||||||
}
|
|
||||||
if let Some(cached_creation_tokens) = usage
|
|
||||||
.and_then(|value| value.get("cache_creation_input_tokens"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
{
|
|
||||||
prompt_details.insert(
|
|
||||||
"cached_creation_tokens".to_string(),
|
|
||||||
Value::from(cached_creation_tokens),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !prompt_details.is_empty() {
|
|
||||||
response["usage"]["prompt_tokens_details"] = Value::Object(prompt_details);
|
|
||||||
}
|
|
||||||
response
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_openai_text_part(text: &mut String, content_parts: &mut Vec<Value>, piece: &str) {
|
|
||||||
if piece.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
text.push_str(piece);
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": piece,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_claude_image_block_to_openai_part(
|
|
||||||
block: &serde_json::Map<String, Value>,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let source = block.get("source")?.as_object()?;
|
|
||||||
match source.get("type")?.as_str()? {
|
|
||||||
"base64" => {
|
|
||||||
let media_type = source
|
|
||||||
.get("media_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let data = source
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": build_data_url(media_type, data),
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"url" => {
|
|
||||||
let url = source
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": url,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_claude_document_block_to_openai_part(
|
|
||||||
block: &serde_json::Map<String, Value>,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let source = block.get("source")?.as_object()?;
|
|
||||||
match source.get("type")?.as_str()? {
|
|
||||||
"base64" => {
|
|
||||||
let media_type = source
|
|
||||||
.get("media_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let data = source
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
if let Some(format) = media_type.strip_prefix("audio/") {
|
|
||||||
return Some(json!({
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": data,
|
|
||||||
"format": format,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Some(json!({
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": build_data_url(media_type, data),
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"url" => {
|
|
||||||
let url = source
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
Some(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": format!("[File: {url}]"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_data_url(media_type: &str, data: &str) -> String {
|
|
||||||
format!("data:{media_type};base64,{data}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_claude_chat_response_to_openai_chat;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_claude_reasoning_cache_usage_and_service_tier() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "msg_123",
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"content": [
|
|
||||||
{ "type": "thinking", "thinking": "step by step", "signature": "sig_123" },
|
|
||||||
{ "type": "text", "text": "hello" }
|
|
||||||
],
|
|
||||||
"stop_reason": "end_turn",
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 11,
|
|
||||||
"output_tokens": 7,
|
|
||||||
"cache_read_input_tokens": 3,
|
|
||||||
"cache_creation_input_tokens": 2
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let report_context = json!({
|
|
||||||
"original_request_body": {
|
|
||||||
"service_tier": "default"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_claude_chat_response_to_openai_chat(&response, &report_context)
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["reasoning_content"],
|
|
||||||
"step by step"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["reasoning_parts"],
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "step by step",
|
|
||||||
"signature": "sig_123"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["usage"]["prompt_tokens_details"]["cached_tokens"],
|
|
||||||
3
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["usage"]["prompt_tokens_details"]["cached_creation_tokens"],
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert_eq!(converted["service_tier"], "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_claude_multimodal_content_into_openai_chat_parts() {
|
|
||||||
let response = json!({
|
|
||||||
"id": "msg_multimodal_123",
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"content": [
|
|
||||||
{ "type": "thinking", "thinking": "step by step" },
|
|
||||||
{ "type": "text", "text": "See attached." },
|
|
||||||
{
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "url",
|
|
||||||
"url": "https://example.com/cat.png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": "application/pdf",
|
|
||||||
"data": "JVBERi0x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "document",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": "audio/mp3",
|
|
||||||
"data": "SUQz"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": "call_1",
|
|
||||||
"name": "lookup",
|
|
||||||
"input": { "city": "Shanghai" }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"stop_reason": "tool_use",
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 9,
|
|
||||||
"output_tokens": 4
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_claude_chat_response_to_openai_chat(&response, &json!({}))
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["reasoning_content"],
|
|
||||||
"step by step"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["content"],
|
|
||||||
json!([
|
|
||||||
{ "type": "text", "text": "See attached." },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": "https://example.com/cat.png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,JVBERi0x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": "SUQz",
|
|
||||||
"format": "mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(converted["choices"][0]["finish_reason"], "tool_calls");
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
|
|
||||||
"{\"city\":\"Shanghai\"}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use super::shared::{
|
|
||||||
build_generated_tool_call_id, canonicalize_tool_arguments, extract_gemini_image_url,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn convert_gemini_chat_response_to_openai_chat(
|
|
||||||
body_json: &Value,
|
|
||||||
report_context: &Value,
|
|
||||||
) -> Option<Value> {
|
|
||||||
let body = body_json.as_object()?;
|
|
||||||
let candidates = body.get("candidates")?.as_array()?;
|
|
||||||
let mut choices = Vec::new();
|
|
||||||
for candidate in candidates {
|
|
||||||
let candidate = candidate.as_object()?;
|
|
||||||
let content = candidate.get("content")?.as_object()?;
|
|
||||||
let parts = content.get("parts")?.as_array()?;
|
|
||||||
let mut text = String::new();
|
|
||||||
let mut content_parts = Vec::new();
|
|
||||||
let mut reasoning_content = String::new();
|
|
||||||
let mut reasoning_parts = Vec::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
let mut has_non_text_content = false;
|
|
||||||
for (index, part) in parts.iter().enumerate() {
|
|
||||||
let part = part.as_object()?;
|
|
||||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
|
||||||
if part
|
|
||||||
.get("thought")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
reasoning_content.push_str(piece);
|
|
||||||
let mut reasoning_part = Map::new();
|
|
||||||
reasoning_part
|
|
||||||
.insert("type".to_string(), Value::String("thinking".to_string()));
|
|
||||||
reasoning_part.insert("thinking".to_string(), Value::String(piece.to_string()));
|
|
||||||
if let Some(signature) = part
|
|
||||||
.get("thoughtSignature")
|
|
||||||
.or_else(|| part.get("thought_signature"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
{
|
|
||||||
reasoning_part.insert(
|
|
||||||
"signature".to_string(),
|
|
||||||
Value::String(signature.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
reasoning_parts.push(Value::Object(reasoning_part));
|
|
||||||
} else {
|
|
||||||
text.push_str(piece);
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": piece,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let tool_name = function_call.get("name")?.as_str()?;
|
|
||||||
let tool_id = function_call
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
|
||||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": tool_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool_name,
|
|
||||||
"arguments": arguments,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else if let Some(rendered_text) = render_gemini_textual_part(part) {
|
|
||||||
text.push_str(rendered_text.as_str());
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": rendered_text,
|
|
||||||
}));
|
|
||||||
} else if let Some(content_part) = convert_gemini_part_to_openai_content_part(part) {
|
|
||||||
if content_part.get("type").and_then(Value::as_str) == Some("text") {
|
|
||||||
if let Some(piece) = content_part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(piece);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
has_non_text_content = true;
|
|
||||||
}
|
|
||||||
content_parts.push(content_part);
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut finish_reason = match candidate.get("finishReason").and_then(Value::as_str) {
|
|
||||||
Some("STOP") => Some("stop"),
|
|
||||||
Some("MAX_TOKENS") => Some("length"),
|
|
||||||
Some(
|
|
||||||
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" | "OTHER",
|
|
||||||
) => Some("content_filter"),
|
|
||||||
Some(other) if !other.is_empty() => Some(other),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
|
|
||||||
finish_reason = Some("tool_calls");
|
|
||||||
}
|
|
||||||
let message_content = if content_parts.is_empty() && !tool_calls.is_empty() {
|
|
||||||
Value::Null
|
|
||||||
} else if has_non_text_content {
|
|
||||||
Value::Array(content_parts)
|
|
||||||
} else {
|
|
||||||
Value::String(text)
|
|
||||||
};
|
|
||||||
let mut message = Map::new();
|
|
||||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
|
||||||
message.insert("content".to_string(), message_content);
|
|
||||||
if !reasoning_content.trim().is_empty() {
|
|
||||||
message.insert(
|
|
||||||
"reasoning_content".to_string(),
|
|
||||||
Value::String(reasoning_content),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !reasoning_parts.is_empty() {
|
|
||||||
message.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
|
|
||||||
}
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
|
||||||
}
|
|
||||||
choices.push(json!({
|
|
||||||
"index": candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
|
|
||||||
"message": Value::Object(message),
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let usage = body.get("usageMetadata").and_then(Value::as_object);
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.and_then(|value| value.get("promptTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let reasoning_tokens = usage
|
|
||||||
.and_then(|value| value.get("thoughtsTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let completion_tokens = usage
|
|
||||||
.and_then(|value| value.get("candidatesTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
+ reasoning_tokens;
|
|
||||||
let total_tokens = usage
|
|
||||||
.and_then(|value| value.get("totalTokenCount"))
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(prompt_tokens + completion_tokens);
|
|
||||||
let model = body
|
|
||||||
.get("modelVersion")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
|
||||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = body
|
|
||||||
.get("responseId")
|
|
||||||
.or_else(|| body.get("_v1internal_response_id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("chatcmpl-local-finalize");
|
|
||||||
Some(json!({
|
|
||||||
"id": id,
|
|
||||||
"object": "chat.completion",
|
|
||||||
"model": model,
|
|
||||||
"choices": choices,
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": prompt_tokens,
|
|
||||||
"completion_tokens": completion_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.map(|mut response| {
|
|
||||||
if reasoning_tokens > 0 {
|
|
||||||
response["usage"]["completion_tokens_details"] =
|
|
||||||
json!({ "reasoning_tokens": reasoning_tokens });
|
|
||||||
}
|
|
||||||
response
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_gemini_textual_part(part: &Map<String, Value>) -> Option<String> {
|
|
||||||
if let Some(code) = part.get("executableCode").and_then(Value::as_object) {
|
|
||||||
let language = code
|
|
||||||
.get("language")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let source = code.get("code").and_then(Value::as_str).unwrap_or_default();
|
|
||||||
return Some(format!("```{language}\n{source}\n```"));
|
|
||||||
}
|
|
||||||
if let Some(result) = part.get("codeExecutionResult").and_then(Value::as_object) {
|
|
||||||
let output = result
|
|
||||||
.get("output")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
return Some(format!("```output\n{output}\n```"));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_gemini_part_to_openai_content_part(part: &Map<String, Value>) -> Option<Value> {
|
|
||||||
if let Some(image_url) = extract_gemini_image_url(part) {
|
|
||||||
return Some(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": image_url,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(inline_data) = part
|
|
||||||
.get("inlineData")
|
|
||||||
.or_else(|| part.get("inline_data"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let mime_type = inline_data
|
|
||||||
.get("mimeType")
|
|
||||||
.or_else(|| inline_data.get("mime_type"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
let data = inline_data
|
|
||||||
.get("data")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
if let Some(format) = mime_type.strip_prefix("audio/") {
|
|
||||||
return Some(json!({
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {
|
|
||||||
"data": data,
|
|
||||||
"format": format,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return Some(json!({
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": format!("data:{mime_type};base64,{data}"),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(file_data) = part
|
|
||||||
.get("fileData")
|
|
||||||
.or_else(|| part.get("file_data"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let file_uri = file_data
|
|
||||||
.get("fileUri")
|
|
||||||
.or_else(|| file_data.get("file_uri"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())?;
|
|
||||||
return Some(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": format!("[File: {file_uri}]"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::convert_gemini_chat_response_to_openai_chat;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_gemini_candidates_reasoning_and_code_execution() {
|
|
||||||
let response = json!({
|
|
||||||
"responseId": "resp_123",
|
|
||||||
"modelVersion": "gemini-2.5-pro",
|
|
||||||
"candidates": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"finishReason": "RECITATION",
|
|
||||||
"content": {
|
|
||||||
"parts": [
|
|
||||||
{ "text": "thinking", "thought": true, "thoughtSignature": "sig_123" },
|
|
||||||
{ "executableCode": { "language": "python", "code": "print(1)" } },
|
|
||||||
{ "codeExecutionResult": { "output": "1" } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"finishReason": "STOP",
|
|
||||||
"content": {
|
|
||||||
"parts": [
|
|
||||||
{ "functionCall": { "id": "call_1", "name": "lookup", "args": { "city": "Shanghai" } } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usageMetadata": {
|
|
||||||
"promptTokenCount": 10,
|
|
||||||
"candidatesTokenCount": 5,
|
|
||||||
"thoughtsTokenCount": 2,
|
|
||||||
"totalTokenCount": 17
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_gemini_chat_response_to_openai_chat(&response, &json!({}))
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(converted["choices"].as_array().expect("choices").len(), 2);
|
|
||||||
assert_eq!(converted["choices"][0]["finish_reason"], "content_filter");
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["reasoning_content"],
|
|
||||||
"thinking"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["reasoning_parts"],
|
|
||||||
json!([
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "thinking",
|
|
||||||
"signature": "sig_123"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
let content = converted["choices"][0]["message"]["content"]
|
|
||||||
.as_str()
|
|
||||||
.expect("content should be string");
|
|
||||||
assert!(content.contains("print(1)"));
|
|
||||||
assert!(content.contains("```output\n1\n```"));
|
|
||||||
assert_eq!(converted["choices"][1]["finish_reason"], "tool_calls");
|
|
||||||
assert_eq!(
|
|
||||||
converted["usage"]["completion_tokens_details"]["reasoning_tokens"],
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert_eq!(converted["usage"]["completion_tokens"], 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_gemini_multimodal_parts_in_openai_chat_response() {
|
|
||||||
let response = json!({
|
|
||||||
"responseId": "resp_mm_123",
|
|
||||||
"modelVersion": "gemini-2.5-pro",
|
|
||||||
"candidates": [{
|
|
||||||
"index": 0,
|
|
||||||
"finishReason": "STOP",
|
|
||||||
"content": {
|
|
||||||
"parts": [
|
|
||||||
{ "text": "Attached." },
|
|
||||||
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } },
|
|
||||||
{ "inlineData": { "mimeType": "application/pdf", "data": "JVBERi0x" } },
|
|
||||||
{ "inline_data": { "mime_type": "audio/mp3", "data": "SUQz" } },
|
|
||||||
{ "fileData": { "fileUri": "https://example.com/report.pdf", "mimeType": "application/pdf" } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
"usageMetadata": {
|
|
||||||
"promptTokenCount": 4,
|
|
||||||
"candidatesTokenCount": 2,
|
|
||||||
"totalTokenCount": 6
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let converted = convert_gemini_chat_response_to_openai_chat(&response, &json!({}))
|
|
||||||
.expect("response should convert");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
converted["choices"][0]["message"]["content"],
|
|
||||||
json!([
|
|
||||||
{ "type": "text", "text": "Attached." },
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": { "url": "data:image/png;base64,iVBORw0KGgo=" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": { "file_data": "data:application/pdf;base64,JVBERi0x" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": { "data": "SUQz", "format": "mp3" }
|
|
||||||
},
|
|
||||||
{ "type": "text", "text": "[File: https://example.com/report.pdf]" }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mod claude_chat;
|
|
||||||
mod gemini_chat;
|
|
||||||
mod shared;
|
|
||||||
|
|
||||||
pub use claude_chat::convert_claude_chat_response_to_openai_chat;
|
|
||||||
pub use gemini_chat::convert_gemini_chat_response_to_openai_chat;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use serde_json::{Map, Value};
|
|
||||||
|
|
||||||
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
|
|
||||||
format!("call_auto_{index}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
|
||||||
match value {
|
|
||||||
Some(Value::String(text)) => text,
|
|
||||||
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
|
||||||
None => "{}".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn extract_gemini_image_url(part: &Map<String, Value>) -> Option<String> {
|
|
||||||
if let Some(inline_data) = part
|
|
||||||
.get("inlineData")
|
|
||||||
.or_else(|| part.get("inline_data"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
{
|
|
||||||
let mime_type = inline_data
|
|
||||||
.get("mimeType")
|
|
||||||
.or_else(|| inline_data.get("mime_type"))
|
|
||||||
.and_then(Value::as_str)?;
|
|
||||||
if !mime_type.starts_with("image/") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let data = inline_data.get("data").and_then(Value::as_str)?;
|
|
||||||
return Some(format!("data:{mime_type};base64,{data}"));
|
|
||||||
}
|
|
||||||
let file_data = part
|
|
||||||
.get("fileData")
|
|
||||||
.or_else(|| part.get("file_data"))
|
|
||||||
.and_then(Value::as_object)?;
|
|
||||||
if file_data
|
|
||||||
.get("mimeType")
|
|
||||||
.or_else(|| file_data.get("mime_type"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|mime_type| !mime_type.starts_with("image/"))
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
file_data
|
|
||||||
.get("fileUri")
|
|
||||||
.or_else(|| file_data.get("file_uri"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,13 @@ use serde_json::{json, Value};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
canonical::{
|
canonical::{
|
||||||
canonical_message_to_openai_chat, canonical_response_format_to_openai,
|
canonical_extension_object_mut, canonical_message_to_openai_chat,
|
||||||
canonical_tool_choice_to_openai, canonical_tool_to_openai, namespace_extension_object,
|
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
|
||||||
openai_content_text, openai_extensions, openai_generation_config,
|
canonical_tool_to_openai, namespace_extension_object, openai_content_text,
|
||||||
openai_message_content_blocks, openai_response_format_to_canonical,
|
openai_extensions, openai_generation_config, openai_message_content_blocks,
|
||||||
openai_responses_extension, openai_role_to_canonical, openai_tool_choice_to_canonical,
|
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
|
||||||
openai_tools_to_canonical, write_openai_generation_config, CanonicalInstruction,
|
openai_tool_choice_to_canonical, openai_tools_to_canonical, write_openai_generation_config,
|
||||||
CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
|
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
|
||||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||||
},
|
},
|
||||||
context::FormatContext,
|
context::FormatContext,
|
||||||
@@ -116,6 +116,13 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
|||||||
"top_logprobs",
|
"top_logprobs",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
if let Some(verbosity) = request.get("verbosity").cloned() {
|
||||||
|
canonical_extension_object_mut(
|
||||||
|
&mut canonical.extensions,
|
||||||
|
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||||
|
)
|
||||||
|
.insert("verbosity".to_string(), verbosity);
|
||||||
|
}
|
||||||
Some(canonical)
|
Some(canonical)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user