refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate

- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦
- 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块
- 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支
- 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合
- 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor
- 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
fawney19
2026-04-07 02:50:19 +08:00
parent 763ff03a7b
commit 5d96d6673b
732 changed files with 28593 additions and 20666 deletions

View File

@@ -0,0 +1,168 @@
use serde_json::{Map, Value};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LocalCoreSyncErrorKind {
InvalidRequest,
Authentication,
PermissionDenied,
NotFound,
RateLimit,
ContextLengthExceeded,
Overloaded,
ServerError,
}
pub fn is_core_error_finalize_kind(report_kind: &str) -> bool {
core_error_default_client_api_format(report_kind).is_some()
}
pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
crate::contracts::core_error_default_client_api_format(report_kind)
}
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
crate::contracts::core_error_background_report_kind(report_kind)
}
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
crate::contracts::core_success_background_report_kind(report_kind)
}
pub fn build_core_error_body_for_client_format(
client_api_format: &str,
message: &str,
code: Option<&str>,
kind: LocalCoreSyncErrorKind,
) -> Option<Value> {
let mut error_object = Map::new();
error_object.insert("message".to_string(), Value::String(message.to_string()));
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" | "openai:cli" | "openai:compact" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(error_object),
)])))
}
"claude:chat" | "claude:cli" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_claude_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([
("type".to_string(), Value::String("error".to_string())),
("error".to_string(), Value::Object(error_object)),
])))
}
"gemini:chat" | "gemini:cli" => Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(Map::from_iter([
(
"code".to_string(),
Value::from(map_local_sync_error_kind_to_gemini_code(kind)),
),
("message".to_string(), Value::String(message.to_string())),
(
"status".to_string(),
Value::String(map_local_sync_error_kind_to_gemini_status(kind).to_string()),
),
])),
)]))),
_ => None,
}
}
fn map_local_sync_error_kind_to_openai_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest => "invalid_request_error",
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::ContextLengthExceeded => "context_length_exceeded",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "server_error",
}
}
fn map_local_sync_error_kind_to_claude_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"invalid_request_error"
}
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "api_error",
}
}
fn map_local_sync_error_kind_to_gemini_code(kind: LocalCoreSyncErrorKind) -> u16 {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
400
}
LocalCoreSyncErrorKind::Authentication => 401,
LocalCoreSyncErrorKind::PermissionDenied => 403,
LocalCoreSyncErrorKind::NotFound => 404,
LocalCoreSyncErrorKind::RateLimit => 429,
LocalCoreSyncErrorKind::Overloaded => 503,
LocalCoreSyncErrorKind::ServerError => 500,
}
}
fn map_local_sync_error_kind_to_gemini_status(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"INVALID_ARGUMENT"
}
LocalCoreSyncErrorKind::Authentication => "UNAUTHENTICATED",
LocalCoreSyncErrorKind::PermissionDenied => "PERMISSION_DENIED",
LocalCoreSyncErrorKind::NotFound => "NOT_FOUND",
LocalCoreSyncErrorKind::RateLimit => "RESOURCE_EXHAUSTED",
LocalCoreSyncErrorKind::Overloaded => "UNAVAILABLE",
LocalCoreSyncErrorKind::ServerError => "INTERNAL",
}
}
#[cfg(test)]
mod tests {
use super::{
build_core_error_body_for_client_format, core_success_background_report_kind,
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
#[test]
fn builds_openai_core_error_body() {
let body = build_core_error_body_for_client_format(
"openai:chat",
"bad request",
Some("invalid_request"),
LocalCoreSyncErrorKind::InvalidRequest,
)
.expect("body should build");
assert_eq!(body["error"]["message"], "bad request");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "invalid_request");
}
#[test]
fn recognizes_finalize_kind_and_success_mapping() {
assert!(is_core_error_finalize_kind("openai_chat_sync_finalize"));
assert_eq!(
core_success_background_report_kind("openai_chat_sync_finalize"),
Some("openai_chat_sync_success")
);
}
}

View File

@@ -0,0 +1,14 @@
mod error;
mod registry;
pub mod request;
pub mod response;
pub use error::{
build_core_error_body_for_client_format, core_error_background_report_kind,
core_error_default_client_api_format, core_success_background_report_kind,
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
pub use registry::{
request_conversion_kind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};

View File

@@ -0,0 +1,160 @@
#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestConversionKind {
ToOpenAIChat,
ToOpenAIFamilyCli,
ToOpenAICompact,
ToClaudeStandard,
ToGeminiStandard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncChatResponseConversionKind {
ToOpenAIChat,
ToClaudeChat,
ToGeminiChat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncCliResponseConversionKind {
ToOpenAIFamilyCli,
ToClaudeCli,
ToGeminiCli,
}
pub fn request_conversion_kind(
client_api_format: &str,
provider_api_format: &str,
) -> Option<RequestConversionKind> {
let client_api_format = client_api_format.trim().to_ascii_lowercase();
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
if client_api_format == provider_api_format {
return None;
}
if !is_standard_api_format(client_api_format.as_str())
|| !is_standard_api_format(provider_api_format.as_str())
{
return None;
}
match provider_api_format.as_str() {
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
"openai:cli" => Some(RequestConversionKind::ToOpenAIFamilyCli),
"openai:compact" => Some(RequestConversionKind::ToOpenAICompact),
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
_ => None,
}
}
pub fn sync_chat_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncChatResponseConversionKind> {
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
match client_api_format.as_str() {
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
"gemini:chat" => Some(SyncChatResponseConversionKind::ToGeminiChat),
_ => None,
}
}
pub fn sync_cli_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncCliResponseConversionKind> {
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
match client_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
_ => None,
}
}
fn is_standard_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat"
| "openai:cli"
| "openai:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
#[cfg(test)]
mod tests {
use super::{
request_conversion_kind, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};
#[test]
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
request_conversion_kind("claude:chat", "openai:chat"),
Some(RequestConversionKind::ToOpenAIChat)
);
assert_eq!(
request_conversion_kind("gemini:chat", "claude:chat"),
Some(RequestConversionKind::ToClaudeStandard)
);
assert_eq!(
request_conversion_kind("gemini:cli", "openai:compact"),
Some(RequestConversionKind::ToOpenAICompact)
);
assert_eq!(
request_conversion_kind("openai:compact", "gemini:cli"),
Some(RequestConversionKind::ToGeminiStandard)
);
assert_eq!(request_conversion_kind("claude:chat", "claude:chat"), None);
}
#[test]
fn sync_response_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
sync_chat_response_conversion_kind("openai:chat", "claude:chat"),
Some(SyncChatResponseConversionKind::ToClaudeChat)
);
assert_eq!(
sync_chat_response_conversion_kind("claude:chat", "gemini:chat"),
Some(SyncChatResponseConversionKind::ToGeminiChat)
);
assert_eq!(
sync_chat_response_conversion_kind("gemini:chat", "openai:chat"),
Some(SyncChatResponseConversionKind::ToOpenAIChat)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:cli", "gemini:cli"),
Some(SyncCliResponseConversionKind::ToGeminiCli)
);
assert_eq!(
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
);
assert_eq!(
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
Some(SyncCliResponseConversionKind::ToClaudeCli)
);
}
}

View File

@@ -0,0 +1,411 @@
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_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"),
true,
)?;
if !blocks.is_empty() {
messages.push(build_claude_message("user", blocks));
}
}
"assistant" => {
let mut blocks = convert_openai_content_to_claude_blocks(
message_object.get("content"),
false,
)?;
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")) {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(request.get("tool_choice")) {
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,
}),
);
}
}
Some(Value::Object(output))
}
fn convert_openai_content_to_claude_blocks(
content: Option<&Value>,
allow_images: bool,
) -> 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" => {
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" if allow_images => {
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)
})
})
.filter(|value| !value.trim().is_empty())?;
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" | "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,
}
}));
}
}
}
_ => {}
}
}
Some(blocks)
}
_ => None,
}
}
fn convert_openai_tools_to_claude(tools: Option<&Value>) -> Option<Vec<Value>> {
let tool_values = tools?.as_array()?;
let mut converted = Vec::new();
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));
}
(!converted.is_empty()).then_some(converted)
}
fn convert_openai_tool_choice_to_claude(tool_choice: Option<&Value>) -> Option<Value> {
let tool_choice = tool_choice?;
match tool_choice {
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,
},
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,
}))
}
_ => None,
}
}
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()))
}

View File

@@ -0,0 +1,483 @@
use std::collections::BTreeMap;
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_as, extract_openai_reasoning_effort,
map_openai_reasoning_effort_to_gemini_budget, parse_openai_stop_sequences, value_as_u64,
};
pub fn convert_openai_chat_request_to_gemini_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 tool_name_by_id = BTreeMap::new();
let mut contents = 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 parts = convert_openai_content_to_gemini_parts(
message_object.get("content"),
true,
)?;
if !parts.is_empty() {
contents.push(json!({
"role": "user",
"parts": parts,
}));
}
}
"assistant" => {
let mut parts = convert_openai_content_to_gemini_parts(
message_object.get("content"),
false,
)?;
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"))?;
tool_name_by_id.insert(tool_call_id.clone(), tool_name.clone());
parts.push(json!({
"functionCall": {
"name": tool_name,
"args": tool_input,
"id": tool_call_id,
}
}));
}
}
if !parts.is_empty() {
contents.push(json!({
"role": "model",
"parts": parts,
}));
}
}
"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_name = tool_name_by_id
.get(&tool_use_id)
.cloned()
.unwrap_or_else(|| tool_use_id.clone());
let tool_result =
parse_openai_tool_result_content(message_object.get("content"));
contents.push(json!({
"role": "user",
"parts": [{
"functionResponse": {
"name": tool_name,
"id": tool_use_id,
"response": {
"result": tool_result,
},
}
}],
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
output.insert(
"contents".to_string(),
Value::Array(compact_gemini_contents(contents)),
);
if upstream_is_stream {
output.insert("stream".to_string(), Value::Bool(true));
}
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(
"systemInstruction".to_string(),
json!({ "parts": [{ "text": system_text }] }),
);
}
let mut generation_config = Map::new();
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))
{
generation_config.insert("maxOutputTokens".to_string(), Value::from(max_tokens));
}
copy_request_number_field_as(
request,
&mut generation_config,
"temperature",
"temperature",
);
copy_request_number_field_as(request, &mut generation_config, "top_p", "topP");
copy_request_number_field_as(request, &mut generation_config, "top_k", "topK");
if let Some(candidate_count) = request
.get("n")
.and_then(value_as_u64)
.filter(|value| *value > 1)
{
generation_config.insert("candidateCount".to_string(), Value::from(candidate_count));
}
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
generation_config.insert("stopSequences".to_string(), Value::Array(stop_sequences));
}
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
if let Some(thinking_budget) =
map_openai_reasoning_effort_to_gemini_budget(reasoning_effort.as_str())
{
generation_config.insert(
"thinkingConfig".to_string(),
json!({
"includeThoughts": true,
"thinkingBudget": thinking_budget,
}),
);
}
}
if let Some(response_format) = request.get("response_format").and_then(Value::as_object) {
if let Some(format_type) = response_format.get("type").and_then(Value::as_str) {
match format_type {
"json_schema" => {
generation_config.insert(
"responseMimeType".to_string(),
Value::String("application/json".to_string()),
);
if let Some(schema) = response_format
.get("json_schema")
.and_then(Value::as_object)
.and_then(|json_schema| json_schema.get("schema"))
.cloned()
{
generation_config.insert("responseSchema".to_string(), schema);
}
}
"json_object" => {
generation_config.insert(
"responseMimeType".to_string(),
Value::String("application/json".to_string()),
);
}
_ => {}
}
}
}
if !generation_config.is_empty() {
output.insert(
"generationConfig".to_string(),
Value::Object(generation_config),
);
}
if let Some(tools) =
convert_openai_tools_to_gemini(request.get("tools"), request.get("web_search_options"))
{
output.insert("tools".to_string(), tools);
}
if let Some(tool_config) = convert_openai_tool_choice_to_gemini(request.get("tool_choice")) {
output.insert("toolConfig".to_string(), tool_config);
}
if let Some(extra_body) = request.get("extra_body").and_then(Value::as_object) {
if let Some(google) = extra_body.get("google").and_then(Value::as_object) {
if let Some(existing) = output
.get_mut("generationConfig")
.and_then(Value::as_object_mut)
{
if let Some(response_modalities) = google.get("response_modalities").cloned() {
existing.insert("responseModalities".to_string(), response_modalities);
}
if let Some(thinking_config) = google.get("thinking_config").cloned() {
existing
.entry("thinkingConfig".to_string())
.or_insert(thinking_config);
}
}
}
}
Some(Value::Object(output))
}
fn convert_openai_content_to_gemini_parts(
content: Option<&Value>,
allow_images: bool,
) -> 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!({ "text": text })])
}
}
Some(Value::Array(parts)) => {
let mut converted = 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" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
converted.push(json!({ "text": text }));
}
}
}
"image_url" | "input_image" if allow_images => {
let image = 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)
})?;
if let Some((mime_type, data)) = parse_data_url(image.as_str()) {
converted.push(json!({
"inlineData": {
"mimeType": mime_type,
"data": data,
}
}));
} else {
converted.push(json!({
"fileData": {
"fileUri": image,
"mimeType": guess_media_type_from_reference(image.as_str(), "image/jpeg"),
}
}));
}
}
"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((mime_type, data)) = parse_data_url(file_data) {
converted.push(json!({
"inlineData": {
"mimeType": mime_type,
"data": data,
}
}));
}
}
}
_ => {}
}
}
Some(converted)
}
_ => None,
}
}
fn convert_openai_tools_to_gemini(
tools: Option<&Value>,
web_search_options: Option<&Value>,
) -> Option<Value> {
let mut result_tools = Vec::new();
let tool_values = tools.and_then(Value::as_array);
let mut declarations = Vec::new();
if let Some(tool_values) = tool_values {
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 declaration = Map::new();
declaration.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description").cloned() {
declaration.insert("description".to_string(), description);
}
declaration.insert(
"parameters".to_string(),
function
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({})),
);
declarations.push(Value::Object(declaration));
}
}
if !declarations.is_empty() {
result_tools.push(json!({ "functionDeclarations": declarations }));
}
if web_search_options.is_some() {
result_tools.push(json!({ "googleSearch": {} }));
}
(!result_tools.is_empty()).then_some(Value::Array(result_tools))
}
fn convert_openai_tool_choice_to_gemini(tool_choice: Option<&Value>) -> Option<Value> {
let tool_choice = tool_choice?;
match tool_choice {
Value::String(value) => {
let mode = match value.trim().to_ascii_lowercase().as_str() {
"none" => "NONE",
"required" => "ANY",
"auto" => "AUTO",
_ => return None,
};
Some(json!({
"functionCallingConfig": {
"mode": mode,
}
}))
}
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!({
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": [function_name],
}
}))
}
_ => None,
}
}
fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for content in contents {
let role = content
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let parts = content
.get("parts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if parts.is_empty() {
continue;
}
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 {
if let Some(last_parts) = last.get_mut("parts").and_then(Value::as_array_mut) {
last_parts.extend(parts);
}
continue;
}
}
compact.push(content);
}
compact
}
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()
}
}

View File

@@ -0,0 +1,8 @@
mod claude;
mod gemini;
mod openai_cli;
mod shared;
pub use claude::convert_openai_chat_request_to_claude_request;
pub use gemini::convert_openai_chat_request_to_gemini_request;
pub use openai_cli::convert_openai_chat_request_to_openai_cli_request;

View File

@@ -0,0 +1,483 @@
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};
pub fn convert_openai_chat_request_to_openai_cli_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 content_items = convert_openai_content_to_openai_cli_items(
message_object.get("content"),
role.as_str(),
)?;
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_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",
"service_tier",
"metadata",
"store",
"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_cli_text_config_from_openai_chat_request(request) {
output.insert("text".to_string(), Value::Object(text));
}
if let Some(tools) = build_openai_cli_tools_from_openai_chat_request(request) {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = build_openai_cli_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_cli_items(
content: Option<&Value>,
role: &str,
) -> Option<Vec<Value>> {
let Some(content) = content else {
return Some(Vec::new());
};
match content {
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_cli_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_cli_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_cli_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));
}
}

View File

@@ -0,0 +1,21 @@
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!({ "input": other })),
Err(_) => Some(json!({ "input": trimmed })),
}
}
}
Some(other) => Some(json!({ "input": other })),
None => Some(json!({})),
}
}

View File

@@ -0,0 +1,12 @@
pub mod from_openai_chat;
pub mod to_openai_chat;
pub use from_openai_chat::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request,
};
pub use to_openai_chat::{
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
};

View File

@@ -0,0 +1,402 @@
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" => {
let mut text_segments = Vec::new();
if let Some(content) = message_object.get("content") {
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) => {
if !text.trim().is_empty() {
text_segments.push(text);
}
}
ClaudeNormalizedBlock::ToolResult {
tool_use_id,
content,
} => {
messages.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
}));
}
ClaudeNormalizedBlock::ToolUse { .. } => {}
}
}
}
let text = text_segments.join("\n\n");
if !text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": text,
}));
}
}
"assistant" => {
let mut text_segments = Vec::new();
let mut tool_calls = Vec::new();
if let Some(content) = message_object.get("content") {
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) => {
if !text.trim().is_empty() {
text_segments.push(text);
}
}
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(),
if text_segments.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text_segments.join("\n\n"))
},
);
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
messages.push(Value::Object(assistant));
}
_ => {}
}
}
}
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("reasoning_effort").is_none() {
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(tool_choice) = normalize_claude_tool_choice_to_openai(request.get("tool_choice"))? {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
#[derive(Debug)]
enum ClaudeNormalizedBlock {
Text(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" | "thinking" => {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
normalized.push(ClaudeNormalizedBlock::Text(text.to_string()));
}
"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 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()?;
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),
}));
}
Some(Some(normalized))
}
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),
}
}
#[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"
);
}
}

View File

@@ -0,0 +1,324 @@
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_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" => {
let mut text_segments = Vec::new();
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
text_segments.push(text.to_string());
}
} else if let Some(function_call) =
part.get("functionCall").and_then(Value::as_object)
{
let name = function_call
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}_{}", name, index));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": canonical_json_string(function_call.get("args").cloned().unwrap_or(Value::Object(Map::new()))),
}
}));
}
}
let mut assistant = Map::new();
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
assistant.insert(
"content".to_string(),
if text_segments.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text_segments.join("\n\n"))
},
);
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
messages.push(Value::Object(assistant));
}
_ => {
let mut text_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() {
text_segments.push(text.to_string());
}
} else if let Some(function_response) =
part.get("functionResponse").and_then(Value::as_object)
{
let name = function_response
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool");
let response_value = function_response
.get("response")
.cloned()
.unwrap_or(Value::Object(Map::new()));
let tool_call_id = function_response
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", name));
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": response_value,
}));
}
}
let text = text_segments.join("\n\n");
if !text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": text,
}));
}
}
}
}
}
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);
if let Some(generation_config) = generation_config {
if let Some(value) = generation_config.get("maxOutputTokens").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.get("topP").cloned() {
output.insert("top_p".to_string(), value);
}
if let Some(value) = generation_config.get("candidateCount").cloned() {
output.insert("n".to_string(), value);
}
if let Some(value) = generation_config.get("stopSequences").cloned() {
output.insert("stop".to_string(), value);
}
if let Some(thinking_budget) = generation_config
.get("thinkingConfig")
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("thinkingBudget"))
.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
.get("responseMimeType")
.and_then(Value::as_str)
.is_some_and(|value| value == "application/json")
{
let response_format = if let Some(schema) = generation_config.get("responseSchema") {
json!({
"type": "json_schema",
"json_schema": {
"name": "response_schema",
"schema": schema,
}
})
} else {
json!({ "type": "json_object" })
};
output.insert("response_format".to_string(), response_format);
}
}
if let Some(value) = request.get("stream").cloned() {
output.insert("stream".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(tool_choice) = normalize_gemini_tool_choice_to_openai(request.get("toolConfig"))? {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
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();
for tool in tools {
let tool = tool.as_object()?;
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),
}));
}
}
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();
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()))),
_ => {
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)
{
Some(Some(json!({
"type": "function",
"function": { "name": name }
})))
} else {
Some(None)
}
}
}
}
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())
}
}

View File

@@ -0,0 +1,9 @@
mod claude;
mod gemini;
mod openai_cli;
mod shared;
pub use claude::normalize_claude_request_to_openai_chat_request;
pub use gemini::normalize_gemini_request_to_openai_chat_request;
pub use openai_cli::normalize_openai_cli_request_to_openai_chat_request;
pub use shared::{extract_openai_text_content, parse_openai_tool_result_content};

View File

@@ -0,0 +1,405 @@
use serde_json::{json, Map, Value};
use super::shared::{extract_openai_text_content, parse_openai_tool_result_content};
use crate::planner::openai::extract_openai_reasoning_effort;
pub fn normalize_openai_cli_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_cli_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",
"parallel_tool_calls",
"stop",
"stream",
] {
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(tools) = normalize_openai_cli_tools_to_openai_chat(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(web_search_options) =
extract_openai_cli_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_cli_tool_choice_to_openai_chat(request.get("tool_choice"))?
{
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn normalize_openai_cli_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_cli_message_content(item_object.get("content"))?;
messages.push(json!({
"role": role,
"content": normalized_content,
}));
}
"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_cli_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 normalize_openai_cli_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_cli_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_cli_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())),
}
}

View File

@@ -0,0 +1,66 @@
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()),
}
}

View File

@@ -0,0 +1,95 @@
use serde_json::{json, Value};
use super::shared::{
build_generated_tool_call_id, extract_openai_assistant_text, 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 = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
content.push(json!({
"type": "text",
"text": text,
}));
}
}
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,
}
}))
}

View File

@@ -0,0 +1,101 @@
use serde_json::{json, Value};
use super::shared::{
build_generated_tool_call_id, extract_openai_assistant_text, 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 first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut parts = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
parts.push(json!({ "text": text }));
}
}
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 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 total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut finish_reason = match first_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";
}
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": [{
"content": {
"role": "model",
"parts": parts,
},
"finishReason": finish_reason,
"index": 0,
}],
"usageMetadata": {
"promptTokenCount": prompt_tokens,
"candidatesTokenCount": completion_tokens,
"totalTokenCount": total_tokens,
}
}))
}

View File

@@ -0,0 +1,12 @@
mod claude_chat;
mod gemini_chat;
mod openai_cli;
mod shared;
pub use claude_chat::convert_openai_chat_response_to_claude_chat;
pub use gemini_chat::convert_openai_chat_response_to_gemini_chat;
pub use openai_cli::convert_openai_chat_response_to_openai_cli;
pub use shared::{
build_openai_cli_response, build_openai_cli_response_with_content,
build_openai_cli_response_with_reasoning,
};

View File

@@ -0,0 +1,150 @@
use serde_json::{json, Value};
use super::shared::{build_openai_cli_response_with_content, canonicalize_tool_arguments};
pub fn convert_openai_chat_response_to_openai_cli(
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();
match message.get("content") {
Some(Value::String(value)) => {
if !value.is_empty() {
message_content.push(json!({
"type": "output_text",
"text": value,
"annotations": []
}));
}
}
Some(Value::Array(parts)) => {
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) {
message_content.push(json!({
"type": "output_text",
"text": piece,
"annotations": []
}));
}
} 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);
}
}
}
}
Some(Value::Null) | None => {}
_ => return None,
}
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");
Some(build_openai_cli_response_with_content(
&response_id,
model,
message_content,
reasoning_summaries,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,159 @@
use serde_json::{json, Map, Value};
pub fn build_openai_cli_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_cli_response_with_content(
response_id,
model,
content,
Vec::new(),
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
)
}
pub fn build_openai_cli_response_with_reasoning(
response_id: &str,
model: &str,
text: &str,
reasoning_summaries: Vec<String>,
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_cli_response_with_content(
response_id,
model,
content,
reasoning_summaries,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
)
}
pub fn build_openai_cli_response_with_content(
response_id: &str,
model: &str,
content: Vec<Value>,
reasoning_summaries: Vec<String>,
function_calls: Vec<Value>,
prompt_tokens: u64,
output_tokens: u64,
total_tokens: u64,
) -> 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": prompt_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
}
})
}
pub(super) fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::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();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::String(text) => serde_json::from_str(&text)
.ok()
.or(Some(Value::String(text))),
other => Some(other),
}
}
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(),
}
}

View File

@@ -0,0 +1,12 @@
pub mod from_openai_chat;
pub mod to_openai_chat;
pub use from_openai_chat::{
build_openai_cli_response, convert_openai_chat_response_to_claude_chat,
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
};
pub use to_openai_chat::{
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
convert_openai_cli_response_to_openai_chat,
};

View File

@@ -0,0 +1,112 @@
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 reasoning_content = String::new();
let mut tool_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"thinking" => {
if let Some(piece) = block
.get("thinking")
.and_then(Value::as_str)
.or_else(|| block.get("text").and_then(Value::as_str))
{
reasoning_content.push_str(piece);
}
}
"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,
}
}));
}
_ => 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 text.is_empty() && !tool_calls.is_empty() {
Value::Null
} 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 !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,
}
}))
}

View File

@@ -0,0 +1,83 @@
use serde_json::{json, Value};
use super::super::from_openai_chat::build_openai_cli_response_with_reasoning;
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub fn convert_claude_cli_response_to_openai_cli(
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 reasoning_summaries = Vec::new();
let mut function_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"thinking" => {
if let Some(piece) = block
.get("thinking")
.and_then(Value::as_str)
.or_else(|| block.get("text").and_then(Value::as_str))
{
if !piece.trim().is_empty() {
reasoning_summaries.push(piece.to_string());
}
}
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let call_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());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
}
_ => continue,
}
}
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 output_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + output_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(build_openai_cli_response_with_reasoning(
response_id,
model,
&text,
reasoning_summaries,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,133 @@
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 first_candidate = candidates.first()?.as_object()?;
let content = first_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 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);
} 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(image_url) = extract_gemini_image_url(part) {
content_parts.push(json!({
"type": "image_url",
"image_url": {
"url": image_url,
}
}));
has_non_text_content = true;
} else {
continue;
}
}
let mut finish_reason = match first_candidate.get("finishReason").and_then(Value::as_str) {
Some("STOP") => Some("stop"),
Some("MAX_TOKENS") => Some("length"),
Some("SAFETY") => 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 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 completion_tokens = usage
.and_then(|value| value.get("candidatesTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
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")
.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 !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": first_candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}

View File

@@ -0,0 +1,106 @@
use serde_json::{json, Value};
use super::super::from_openai_chat::build_openai_cli_response_with_content;
use super::shared::{
build_generated_tool_call_id, canonicalize_tool_arguments, extract_gemini_image_url,
};
pub fn convert_gemini_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut message_content = Vec::new();
let mut reasoning_summaries = Vec::new();
let mut function_calls = Vec::new();
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)
{
if !piece.trim().is_empty() {
reasoning_summaries.push(piece.to_string());
}
} else {
message_content.push(json!({
"type": "output_text",
"text": piece,
"annotations": []
}));
}
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
let tool_name = function_call.get("name")?.as_str()?;
let call_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());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
} else if let Some(image_url) = extract_gemini_image_url(part) {
message_content.push(json!({
"type": "output_image",
"image_url": image_url,
}));
} else {
continue;
}
}
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 output_tokens = usage
.map(|value| {
value
.get("candidatesTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
+ value
.get("thoughtsTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
})
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_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 response_id = body
.get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(build_openai_cli_response_with_content(
response_id,
model,
message_content,
reasoning_summaries,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,12 @@
mod claude_chat;
mod claude_cli;
mod gemini_chat;
mod gemini_cli;
mod openai_cli;
mod shared;
pub use claude_chat::convert_claude_chat_response_to_openai_chat;
pub use claude_cli::convert_claude_cli_response_to_openai_cli;
pub use gemini_chat::convert_gemini_chat_response_to_openai_chat;
pub use gemini_cli::convert_gemini_cli_response_to_openai_cli;
pub use openai_cli::convert_openai_cli_response_to_openai_chat;

View File

@@ -0,0 +1,238 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub fn convert_openai_cli_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 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)
{
text.push_str(piece);
content_parts.push(json!({
"type": "text",
"text": piece,
}));
}
} 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;
}
}
}
}
}
"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 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 !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,
}
}))
}
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))
}

View File

@@ -0,0 +1,36 @@
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").and_then(Value::as_object) {
let mime_type = inline_data.get("mimeType").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").and_then(Value::as_object)?;
if file_data
.get("mimeType")
.and_then(Value::as_str)
.is_some_and(|mime_type| !mime_type.starts_with("image/"))
{
return None;
}
file_data
.get("fileUri")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}