mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
refactor: 抽离 AI pipeline 与调度共享能力逻辑
This commit is contained in:
@@ -9,8 +9,9 @@ pub use error::{
|
||||
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||
};
|
||||
pub use registry::{
|
||||
request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_transport_supported, sync_chat_response_conversion_kind,
|
||||
request_candidate_api_formats, request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_conversion_transport_supported,
|
||||
request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ use aether_provider_transport::{
|
||||
pub enum RequestConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToOpenAIFamilyCli,
|
||||
ToOpenAICompact,
|
||||
ToClaudeStandard,
|
||||
ToGeminiStandard,
|
||||
}
|
||||
@@ -33,6 +32,28 @@ pub enum SyncCliResponseConversionKind {
|
||||
ToGeminiCli,
|
||||
}
|
||||
|
||||
const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
];
|
||||
|
||||
pub fn request_candidate_api_formats(
|
||||
client_api_format: &str,
|
||||
_require_streaming: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
match client_api_format.as_str() {
|
||||
"openai:chat" | "openai:cli" | "claude:chat" | "claude:cli" | "gemini:chat"
|
||||
| "gemini:cli" => NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS.to_vec(),
|
||||
"openai:compact" => vec!["openai:compact"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_conversion_kind(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
@@ -47,11 +68,13 @@ pub fn request_conversion_kind(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if client_api_format == "openai:compact" || provider_api_format == "openai:compact" {
|
||||
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,
|
||||
@@ -70,6 +93,7 @@ pub fn sync_chat_response_conversion_kind(
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
|
||||
match client_api_format.as_str() {
|
||||
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
|
||||
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
|
||||
@@ -90,14 +114,54 @@ pub fn sync_cli_response_conversion_kind(
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
|
||||
match client_api_format.as_str() {
|
||||
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
|
||||
"openai:cli" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
|
||||
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
|
||||
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_conversion_requires_enable_flag(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
||||
match (
|
||||
api_data_format_id(client_api_format.as_str()),
|
||||
api_data_format_id(provider_api_format.as_str()),
|
||||
) {
|
||||
(Some(client_data_format), Some(provider_data_format)) => {
|
||||
client_data_format != provider_data_format
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_pair_allowed_for_transport(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
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 true;
|
||||
}
|
||||
if request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str()).is_none() {
|
||||
return false;
|
||||
}
|
||||
if !request_conversion_requires_enable_flag(
|
||||
client_api_format.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
transport.provider.enable_format_conversion
|
||||
}
|
||||
|
||||
pub fn request_conversion_transport_supported(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
_kind: RequestConversionKind,
|
||||
@@ -155,13 +219,23 @@ fn is_standard_api_format(api_format: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn api_data_format_id(api_format: &str) -> Option<&'static str> {
|
||||
match api_format {
|
||||
"claude:chat" | "claude:cli" => Some("claude"),
|
||||
"gemini:chat" | "gemini:cli" => Some("gemini"),
|
||||
"openai:chat" => Some("openai_chat"),
|
||||
"openai:cli" | "openai:compact" => Some("openai_responses"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_transport_supported, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
request_candidate_api_formats, request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_conversion_transport_supported,
|
||||
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
|
||||
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
|
||||
};
|
||||
use aether_provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
@@ -171,7 +245,15 @@ mod tests {
|
||||
#[test]
|
||||
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
request_conversion_kind("claude:chat", "openai:chat"),
|
||||
request_conversion_kind("openai:chat", "openai:cli"),
|
||||
Some(RequestConversionKind::ToOpenAIFamilyCli)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "claude:cli"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:cli", "openai:chat"),
|
||||
Some(RequestConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -179,12 +261,20 @@ mod tests {
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:cli", "openai:compact"),
|
||||
Some(RequestConversionKind::ToOpenAICompact)
|
||||
request_conversion_kind("openai:compact", "gemini:cli"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:compact", "gemini:cli"),
|
||||
Some(RequestConversionKind::ToGeminiStandard)
|
||||
request_conversion_kind("gemini:cli", "openai:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "openai:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("claude:chat", "claude:cli"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(request_conversion_kind("claude:chat", "claude:chat"), None);
|
||||
}
|
||||
@@ -208,15 +298,84 @@ mod tests {
|
||||
Some(SyncCliResponseConversionKind::ToGeminiCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
|
||||
sync_cli_response_conversion_kind("claude:chat", "openai:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:compact", "claude:cli"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToClaudeCli)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_candidate_registry_excludes_compact_as_cross_format_target() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:chat", false),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:cli", false),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("claude:cli", false),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:compact", false),
|
||||
vec!["openai:compact"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversion_enable_flag_only_applies_to_real_data_format_conversions() {
|
||||
assert!(!request_conversion_requires_enable_flag(
|
||||
"claude:chat",
|
||||
"claude:cli"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"openai:chat",
|
||||
"openai:cli"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"openai:cli",
|
||||
"openai:chat"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"openai:chat",
|
||||
"gemini:chat"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversion_helpers_follow_transport_api_format() {
|
||||
let transport = GatewayProviderTransportSnapshot {
|
||||
|
||||
@@ -133,10 +133,15 @@ pub fn convert_openai_chat_request_to_claude_request(
|
||||
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")) {
|
||||
if let Some(tools) =
|
||||
convert_openai_tools_to_claude(request.get("tools"), request.get("web_search_options"))
|
||||
{
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(request.get("tool_choice")) {
|
||||
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(
|
||||
request.get("tool_choice"),
|
||||
request.get("parallel_tool_calls"),
|
||||
) {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
if let Some(metadata) = request.get("metadata").cloned() {
|
||||
@@ -250,51 +255,106 @@ fn convert_openai_content_to_claude_blocks(
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_openai_tools_to_claude(tools: Option<&Value>) -> Option<Vec<Value>> {
|
||||
let tool_values = tools?.as_array()?;
|
||||
fn convert_openai_tools_to_claude(
|
||||
tools: Option<&Value>,
|
||||
web_search_options: Option<&Value>,
|
||||
) -> Option<Vec<Value>> {
|
||||
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;
|
||||
if let Some(tool_values) = tools.and_then(Value::as_array) {
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
if tool_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value != "function")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let function = tool_object.get("function")?.as_object()?;
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut converted_tool = Map::new();
|
||||
converted_tool.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = function.get("description").cloned() {
|
||||
converted_tool.insert("description".to_string(), description);
|
||||
}
|
||||
converted_tool.insert(
|
||||
"input_schema".to_string(),
|
||||
function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
converted.push(Value::Object(converted_tool));
|
||||
}
|
||||
let function = tool_object.get("function")?.as_object()?;
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut converted_tool = Map::new();
|
||||
converted_tool.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = function.get("description").cloned() {
|
||||
converted_tool.insert("description".to_string(), description);
|
||||
}
|
||||
converted_tool.insert(
|
||||
"input_schema".to_string(),
|
||||
function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
converted.push(Value::Object(converted_tool));
|
||||
}
|
||||
if let Some(web_search_tool) =
|
||||
convert_openai_web_search_options_to_claude_tool(web_search_options)
|
||||
{
|
||||
converted.push(web_search_tool);
|
||||
}
|
||||
(!converted.is_empty()).then_some(converted)
|
||||
}
|
||||
|
||||
fn convert_openai_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() {
|
||||
fn convert_openai_web_search_options_to_claude_tool(
|
||||
web_search_options: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let web_search_options = web_search_options?.as_object()?;
|
||||
let mut tool = Map::new();
|
||||
tool.insert(
|
||||
"type".to_string(),
|
||||
Value::String("web_search_20250305".to_string()),
|
||||
);
|
||||
tool.insert("name".to_string(), Value::String("web_search".to_string()));
|
||||
if let Some(user_location) = web_search_options
|
||||
.get("user_location")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let approximate = user_location
|
||||
.get("approximate")
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or(user_location);
|
||||
let mut location = Map::new();
|
||||
location.insert("type".to_string(), Value::String("approximate".to_string()));
|
||||
for field in ["city", "country", "region", "timezone"] {
|
||||
if let Some(value) = approximate.get(field).cloned() {
|
||||
location.insert(field.to_string(), value);
|
||||
}
|
||||
}
|
||||
if location.len() > 1 {
|
||||
tool.insert("user_location".to_string(), Value::Object(location));
|
||||
}
|
||||
}
|
||||
if let Some(max_uses) = web_search_options
|
||||
.get("search_context_size")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some(1u64),
|
||||
"medium" => Some(5u64),
|
||||
"high" => Some(10u64),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
tool.insert("max_uses".to_string(), Value::from(max_uses));
|
||||
}
|
||||
Some(Value::Object(tool))
|
||||
}
|
||||
|
||||
fn convert_openai_tool_choice_to_claude(
|
||||
tool_choice: Option<&Value>,
|
||||
parallel_tool_calls: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let mut converted = match tool_choice {
|
||||
Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"none" => Some(json!({ "type": "none" })),
|
||||
"required" => Some(json!({ "type": "any" })),
|
||||
"auto" => Some(json!({ "type": "auto" })),
|
||||
_ => None,
|
||||
},
|
||||
Value::Object(object) => {
|
||||
Some(Value::Object(object)) => {
|
||||
let function_name = object
|
||||
.get("function")
|
||||
.and_then(Value::as_object)
|
||||
@@ -307,8 +367,29 @@ fn convert_openai_tool_choice_to_claude(tool_choice: Option<&Value>) -> Option<V
|
||||
"name": function_name,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
Some(_) => None,
|
||||
None => None,
|
||||
};
|
||||
if let Some(parallel_tool_calls) = parallel_tool_calls.and_then(Value::as_bool) {
|
||||
if converted.is_none() {
|
||||
converted = Some(json!({ "type": "auto" }));
|
||||
}
|
||||
if let Some(object) = converted.as_mut().and_then(Value::as_object_mut) {
|
||||
let choice_type = object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if choice_type != "none" {
|
||||
object.insert(
|
||||
"disable_parallel_tool_use".to_string(),
|
||||
Value::Bool(!parallel_tool_calls),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
converted
|
||||
}
|
||||
|
||||
fn compact_claude_messages(messages: Vec<Value>) -> Vec<Value> {
|
||||
@@ -409,3 +490,69 @@ fn parse_data_url(value: &str) -> Option<(String, String)> {
|
||||
}
|
||||
Some((media_type.to_string(), data.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_request_to_claude_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn maps_openai_web_search_options_to_claude_builtin_tool() {
|
||||
let request = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "weather in shanghai" }
|
||||
],
|
||||
"web_search_options": {
|
||||
"search_context_size": "medium",
|
||||
"user_location": {
|
||||
"approximate": {
|
||||
"city": "Shanghai",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(converted["tools"][0]["type"], "web_search_20250305");
|
||||
assert_eq!(converted["tools"][0]["name"], "web_search");
|
||||
assert_eq!(converted["tools"][0]["max_uses"], 5);
|
||||
assert_eq!(
|
||||
converted["tools"][0]["user_location"],
|
||||
json!({
|
||||
"type": "approximate",
|
||||
"city": "Shanghai",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_parallel_tool_calls_to_disable_parallel_tool_use() {
|
||||
let request = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "call tools if needed" }
|
||||
],
|
||||
"parallel_tool_calls": true
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(
|
||||
converted["tool_choice"],
|
||||
json!({
|
||||
"type": "auto",
|
||||
"disable_parallel_tool_use": false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,13 @@ pub fn convert_openai_chat_request_to_gemini_request(
|
||||
{
|
||||
generation_config.insert("candidateCount".to_string(), Value::from(candidate_count));
|
||||
}
|
||||
if let Some(seed) = request.get("seed").and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|raw| i64::try_from(raw).ok()))
|
||||
}) {
|
||||
generation_config.insert("seed".to_string(), Value::from(seed));
|
||||
}
|
||||
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
|
||||
generation_config.insert("stopSequences".to_string(), Value::Array(stop_sequences));
|
||||
}
|
||||
@@ -342,6 +349,9 @@ fn convert_openai_tools_to_gemini(
|
||||
let mut result_tools = Vec::new();
|
||||
let tool_values = tools.and_then(Value::as_array);
|
||||
let mut declarations = Vec::new();
|
||||
let mut google_search = web_search_options.is_some();
|
||||
let mut code_execution = false;
|
||||
let mut url_context = false;
|
||||
if let Some(tool_values) = tool_values {
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
@@ -358,6 +368,22 @@ fn convert_openai_tools_to_gemini(
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
match normalize_openai_builtin_gemini_tool_name(name) {
|
||||
Some("googleSearch") => {
|
||||
google_search = true;
|
||||
continue;
|
||||
}
|
||||
Some("codeExecution") => {
|
||||
code_execution = true;
|
||||
continue;
|
||||
}
|
||||
Some("urlContext") => {
|
||||
url_context = true;
|
||||
continue;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => {}
|
||||
}
|
||||
let mut declaration = Map::new();
|
||||
declaration.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = function.get("description").cloned() {
|
||||
@@ -373,12 +399,18 @@ fn convert_openai_tools_to_gemini(
|
||||
declarations.push(Value::Object(declaration));
|
||||
}
|
||||
}
|
||||
if code_execution {
|
||||
result_tools.push(json!({ "codeExecution": {} }));
|
||||
}
|
||||
if google_search {
|
||||
result_tools.push(json!({ "googleSearch": {} }));
|
||||
}
|
||||
if url_context {
|
||||
result_tools.push(json!({ "urlContext": {} }));
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -481,3 +513,109 @@ fn guess_media_type_from_reference(reference: &str, default_mime: &str) -> Strin
|
||||
default_mime.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_openai_builtin_gemini_tool_name(name: &str) -> Option<&'static str> {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"googlesearch" => Some("googleSearch"),
|
||||
"codeexecution" => Some("codeExecution"),
|
||||
"urlcontext" => Some("urlContext"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_request_to_gemini_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn maps_seed_and_builtin_gemini_tools_from_openai_request() {
|
||||
let request = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "use tools" }
|
||||
],
|
||||
"seed": 7,
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "googleSearch",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "codeExecution",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "urlContext",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookupWeather",
|
||||
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_request_to_gemini_request(&request, "gemini-2.5-pro", false)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(converted["generationConfig"]["seed"], 7);
|
||||
assert_eq!(converted["tools"][0], json!({ "codeExecution": {} }));
|
||||
assert_eq!(converted["tools"][1], json!({ "googleSearch": {} }));
|
||||
assert_eq!(converted["tools"][2], json!({ "urlContext": {} }));
|
||||
assert_eq!(
|
||||
converted["tools"][3],
|
||||
json!({
|
||||
"functionDeclarations": [
|
||||
{
|
||||
"name": "lookupWeather",
|
||||
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_google_search_when_web_search_options_are_also_present() {
|
||||
let request = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "search" }
|
||||
],
|
||||
"web_search_options": {},
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "googleSearch",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_request_to_gemini_request(&request, "gemini-2.5-pro", false)
|
||||
.expect("request should convert");
|
||||
|
||||
let tools = converted["tools"]
|
||||
.as_array()
|
||||
.expect("tools should be array");
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0], json!({ "googleSearch": {} }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ 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};
|
||||
use crate::planner::openai::{
|
||||
copy_request_number_field, extract_openai_reasoning_effort, value_as_u64,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
body_json: &Value,
|
||||
@@ -34,10 +36,21 @@ pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
}
|
||||
}
|
||||
"user" | "assistant" => {
|
||||
let content_items = convert_openai_content_to_openai_cli_items(
|
||||
let mut content_items = convert_openai_content_to_openai_cli_items(
|
||||
message_object.get("content"),
|
||||
role.as_str(),
|
||||
)?;
|
||||
if role == "assistant" {
|
||||
if let Some(refusal) = message_object.get("refusal").and_then(Value::as_str)
|
||||
{
|
||||
if !refusal.trim().is_empty() {
|
||||
content_items.push(json!({
|
||||
"type": "refusal",
|
||||
"refusal": refusal,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !content_items.is_empty() {
|
||||
input_items.push(json!({
|
||||
"type": "message",
|
||||
@@ -141,7 +154,11 @@ pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
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) {
|
||||
if let Some(max_tokens) = request
|
||||
.get("max_completion_tokens")
|
||||
.and_then(value_as_u64)
|
||||
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
|
||||
{
|
||||
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
|
||||
}
|
||||
copy_request_number_field(request, &mut output, "temperature");
|
||||
@@ -151,9 +168,12 @@ pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
|
||||
for passthrough_key in [
|
||||
"prompt_cache_key",
|
||||
"prompt_cache_retention",
|
||||
"service_tier",
|
||||
"metadata",
|
||||
"store",
|
||||
"user",
|
||||
"safety_identifier",
|
||||
"previous_response_id",
|
||||
"truncation",
|
||||
"stop",
|
||||
@@ -481,3 +501,71 @@ fn copy_request_bool_field(
|
||||
output.insert(field.to_string(), Value::Bool(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_request_to_openai_cli_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_shared_openai_chat_controls_when_converting_to_openai_cli() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_completion_tokens": 256,
|
||||
"verbosity": "low",
|
||||
"prompt_cache_key": "cache-key-123",
|
||||
"prompt_cache_retention": "persist",
|
||||
"service_tier": "priority",
|
||||
"user": "user-123",
|
||||
"safety_identifier": "safe-123",
|
||||
"top_logprobs": 3,
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_request_to_openai_cli_request(
|
||||
&request,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("chat request should convert to responses");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-5-upstream");
|
||||
assert_eq!(converted["max_output_tokens"], 256);
|
||||
assert_eq!(converted["prompt_cache_key"], "cache-key-123");
|
||||
assert_eq!(converted["prompt_cache_retention"], "persist");
|
||||
assert_eq!(converted["service_tier"], "priority");
|
||||
assert_eq!(converted["user"], "user-123");
|
||||
assert_eq!(converted["safety_identifier"], "safe-123");
|
||||
assert_eq!(converted["top_logprobs"], 3);
|
||||
assert_eq!(converted["text"]["verbosity"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_assistant_refusal_when_converting_to_openai_cli() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"refusal": "cannot comply"
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_request_to_openai_cli_request(
|
||||
&request,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("chat request should convert to responses");
|
||||
|
||||
assert_eq!(
|
||||
converted["input"][0]["content"],
|
||||
json!([{
|
||||
"type": "refusal",
|
||||
"refusal": "cannot comply"
|
||||
}])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,9 +141,20 @@ pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Opt
|
||||
if let Some(tools) = normalize_claude_tools_to_openai(request.get("tools"))? {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(web_search_options) = extract_claude_web_search_options(request.get("tools")) {
|
||||
output.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
if let Some(tool_choice) = normalize_claude_tool_choice_to_openai(request.get("tool_choice"))? {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
if let Some(parallel_tool_calls) =
|
||||
extract_claude_parallel_tool_calls(request.get("tool_choice"))
|
||||
{
|
||||
output.insert(
|
||||
"parallel_tool_calls".to_string(),
|
||||
Value::Bool(parallel_tool_calls),
|
||||
);
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
@@ -262,6 +273,13 @@ fn normalize_claude_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<
|
||||
let mut normalized = Vec::new();
|
||||
for tool in tools {
|
||||
let tool = tool.as_object()?;
|
||||
if tool
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.starts_with("web_search"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = tool
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
@@ -288,7 +306,62 @@ fn normalize_claude_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<
|
||||
"function": Value::Object(function),
|
||||
}));
|
||||
}
|
||||
Some(Some(normalized))
|
||||
if normalized.is_empty() {
|
||||
Some(None)
|
||||
} else {
|
||||
Some(Some(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_claude_web_search_options(tools: Option<&Value>) -> Option<Value> {
|
||||
let tools = tools?.as_array()?;
|
||||
for tool in tools {
|
||||
let tool = tool.as_object()?;
|
||||
let tool_type = tool
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !tool_type.starts_with("web_search") {
|
||||
continue;
|
||||
}
|
||||
let mut options = Map::new();
|
||||
if let Some(max_uses) = tool.get("max_uses").and_then(Value::as_u64) {
|
||||
let search_context_size = if max_uses <= 1 {
|
||||
"low"
|
||||
} else if max_uses <= 5 {
|
||||
"medium"
|
||||
} else {
|
||||
"high"
|
||||
};
|
||||
options.insert(
|
||||
"search_context_size".to_string(),
|
||||
Value::String(search_context_size.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(user_location) = tool.get("user_location").and_then(Value::as_object) {
|
||||
let mut approximate = Map::new();
|
||||
for field in ["city", "country", "region", "timezone"] {
|
||||
if let Some(value) = user_location.get(field).cloned() {
|
||||
approximate.insert(field.to_string(), value);
|
||||
}
|
||||
}
|
||||
if !approximate.is_empty() {
|
||||
options.insert(
|
||||
"user_location".to_string(),
|
||||
json!({
|
||||
"type": "approximate",
|
||||
"approximate": approximate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !options.is_empty() {
|
||||
return Some(Value::Object(options));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_claude_tool_choice_to_openai(tool_choice: Option<&Value>) -> Option<Option<Value>> {
|
||||
@@ -334,6 +407,23 @@ fn normalize_claude_tool_choice_to_openai(tool_choice: Option<&Value>) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_claude_parallel_tool_calls(tool_choice: Option<&Value>) -> Option<bool> {
|
||||
let tool_choice = tool_choice?.as_object()?;
|
||||
let choice_type = tool_choice
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if choice_type == "none" {
|
||||
return None;
|
||||
}
|
||||
tool_choice
|
||||
.get("disable_parallel_tool_use")
|
||||
.and_then(Value::as_bool)
|
||||
.map(|value| !value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_claude_request_to_openai_chat_request;
|
||||
@@ -399,4 +489,55 @@ mod tests {
|
||||
"toolu_explicit_1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_claude_web_search_and_parallel_settings() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"tools": [
|
||||
{
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
"max_uses": 10,
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"city": "Shanghai",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
"type": "auto",
|
||||
"disable_parallel_tool_use": true
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "find something"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let normalized = normalize_claude_request_to_openai_chat_request(&request)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(
|
||||
normalized["web_search_options"]["search_context_size"],
|
||||
"high"
|
||||
);
|
||||
assert_eq!(
|
||||
normalized["web_search_options"]["user_location"],
|
||||
json!({
|
||||
"type": "approximate",
|
||||
"approximate": {
|
||||
"city": "Shanghai",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(normalized["parallel_tool_calls"], false);
|
||||
assert!(normalized.get("tools").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +152,15 @@ pub fn normalize_gemini_request_to_openai_chat_request(
|
||||
if let Some(value) = generation_config.get("topP").cloned() {
|
||||
output.insert("top_p".to_string(), value);
|
||||
}
|
||||
if let Some(value) = generation_config.get("topK").cloned() {
|
||||
output.insert("top_k".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("seed").cloned() {
|
||||
output.insert("seed".to_string(), value);
|
||||
}
|
||||
if let Some(value) = generation_config.get("stopSequences").cloned() {
|
||||
output.insert("stop".to_string(), value);
|
||||
}
|
||||
@@ -196,6 +202,9 @@ pub fn normalize_gemini_request_to_openai_chat_request(
|
||||
if let Some(tools) = normalize_gemini_tools_to_openai(request.get("tools"))? {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(web_search_options) = extract_gemini_web_search_options(request.get("tools")) {
|
||||
output.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
if let Some(tool_choice) = normalize_gemini_tool_choice_to_openai(request.get("toolConfig"))? {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
@@ -230,8 +239,16 @@ fn normalize_gemini_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<
|
||||
};
|
||||
let tools = tools.as_array()?;
|
||||
let mut normalized = Vec::new();
|
||||
let mut has_code_execution = false;
|
||||
let mut has_url_context = false;
|
||||
for tool in tools {
|
||||
let tool = tool.as_object()?;
|
||||
if tool.get("codeExecution").is_some() || tool.get("code_execution").is_some() {
|
||||
has_code_execution = true;
|
||||
}
|
||||
if tool.get("urlContext").is_some() || tool.get("url_context").is_some() {
|
||||
has_url_context = true;
|
||||
}
|
||||
let declarations = tool
|
||||
.get("functionDeclarations")
|
||||
.or_else(|| tool.get("function_declarations"))
|
||||
@@ -269,7 +286,17 @@ fn normalize_gemini_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<
|
||||
}));
|
||||
}
|
||||
}
|
||||
Some(Some(normalized))
|
||||
if has_code_execution {
|
||||
normalized.push(build_openai_builtin_gemini_tool("codeExecution"));
|
||||
}
|
||||
if has_url_context {
|
||||
normalized.push(build_openai_builtin_gemini_tool("urlContext"));
|
||||
}
|
||||
if normalized.is_empty() {
|
||||
Some(None)
|
||||
} else {
|
||||
Some(Some(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_gemini_tool_choice_to_openai(tool_config: Option<&Value>) -> Option<Option<Value>> {
|
||||
@@ -287,27 +314,50 @@ fn normalize_gemini_tool_choice_to_openai(tool_config: Option<&Value>) -> Option
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_uppercase();
|
||||
if let Some(name) = function_config
|
||||
.get("allowedFunctionNames")
|
||||
.or_else(|| function_config.get("allowed_function_names"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|values| values.first())
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(Some(json!({
|
||||
"type": "function",
|
||||
"function": { "name": name }
|
||||
})));
|
||||
}
|
||||
match mode.as_str() {
|
||||
"NONE" => Some(Some(Value::String("none".to_string()))),
|
||||
"AUTO" => Some(Some(Value::String("auto".to_string()))),
|
||||
"ANY" | "REQUIRED" => Some(Some(Value::String("required".to_string()))),
|
||||
_ => {
|
||||
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)
|
||||
}
|
||||
_ => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_gemini_web_search_options(tools: Option<&Value>) -> Option<Value> {
|
||||
let tools = tools?.as_array()?;
|
||||
for tool in tools {
|
||||
let tool = tool.as_object()?;
|
||||
if tool.get("googleSearch").is_some() || tool.get("google_search").is_some() {
|
||||
return Some(json!({}));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_openai_builtin_gemini_tool(name: &str) -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
|
||||
@@ -322,3 +372,90 @@ fn extract_gemini_model_from_path(path: &str) -> Option<String> {
|
||||
Some(model.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_gemini_request_to_openai_chat_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalizes_gemini_seed_builtin_tools_and_specific_tool_choice() {
|
||||
let request = json!({
|
||||
"model": "gemini-2.5-pro",
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [{ "text": "use tools" }]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 256,
|
||||
"topK": 20,
|
||||
"seed": 7
|
||||
},
|
||||
"tools": [
|
||||
{ "googleSearch": {} },
|
||||
{ "codeExecution": {} },
|
||||
{ "urlContext": {} },
|
||||
{
|
||||
"functionDeclarations": [
|
||||
{
|
||||
"name": "lookupWeather",
|
||||
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": ["lookupWeather"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let normalized = normalize_gemini_request_to_openai_chat_request(
|
||||
&request,
|
||||
"/v1beta/models/gemini:generateContent",
|
||||
)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(normalized["max_completion_tokens"], 256);
|
||||
assert_eq!(normalized["top_k"], 20);
|
||||
assert_eq!(normalized["seed"], 7);
|
||||
assert_eq!(normalized["web_search_options"], json!({}));
|
||||
assert_eq!(
|
||||
normalized["tool_choice"],
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": { "name": "lookupWeather" }
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
normalized["tools"],
|
||||
json!([
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookupWeather",
|
||||
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "codeExecution",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "urlContext",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,15 @@ pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) ->
|
||||
"metadata",
|
||||
"store",
|
||||
"service_tier",
|
||||
"prompt_cache_key",
|
||||
"prompt_cache_retention",
|
||||
"parallel_tool_calls",
|
||||
"stop",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"user",
|
||||
"safety_identifier",
|
||||
"top_logprobs",
|
||||
] {
|
||||
if let Some(value) = request.get(passthrough_key) {
|
||||
output.insert(passthrough_key.to_string(), value.clone());
|
||||
@@ -56,6 +62,14 @@ pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) ->
|
||||
{
|
||||
output.insert("response_format".to_string(), response_format);
|
||||
}
|
||||
if let Some(verbosity) = request
|
||||
.get("text")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|text| text.get("verbosity"))
|
||||
.cloned()
|
||||
{
|
||||
output.insert("verbosity".to_string(), verbosity);
|
||||
}
|
||||
if let Some(tools) = normalize_openai_cli_tools_to_openai_chat(request.get("tools"))? {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
@@ -129,10 +143,17 @@ fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) ->
|
||||
}
|
||||
let normalized_content =
|
||||
normalize_openai_cli_message_content(item_object.get("content"))?;
|
||||
messages.push(json!({
|
||||
"role": role,
|
||||
"content": normalized_content,
|
||||
}));
|
||||
let mut message = serde_json::Map::new();
|
||||
message.insert("role".to_string(), Value::String(role.clone()));
|
||||
message.insert("content".to_string(), normalized_content);
|
||||
if role == "assistant" {
|
||||
if let Some(refusal) =
|
||||
extract_openai_cli_message_refusal(item_object.get("content"))?
|
||||
{
|
||||
message.insert("refusal".to_string(), Value::String(refusal));
|
||||
}
|
||||
}
|
||||
messages.push(Value::Object(message));
|
||||
}
|
||||
"function_call" => {
|
||||
let tool_name = item_object
|
||||
@@ -293,6 +314,39 @@ fn normalize_openai_cli_message_content(content: Option<&Value>) -> Option<Value
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_openai_cli_message_refusal(content: Option<&Value>) -> Option<Option<String>> {
|
||||
let Some(content) = content else {
|
||||
return Some(None);
|
||||
};
|
||||
match content {
|
||||
Value::Array(parts) => {
|
||||
let mut refusals = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if part_type == "refusal" {
|
||||
if let Some(refusal) = part_object.get("refusal").and_then(Value::as_str) {
|
||||
if !refusal.trim().is_empty() {
|
||||
refusals.push(refusal.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if refusals.is_empty() {
|
||||
Some(None)
|
||||
} else {
|
||||
Some(Some(refusals.join("\n")))
|
||||
}
|
||||
}
|
||||
_ => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_tools_to_openai_chat(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
|
||||
let Some(Value::Array(tool_values)) = tools else {
|
||||
return Some(None);
|
||||
@@ -403,3 +457,109 @@ fn normalize_openai_cli_tool_choice_to_openai_chat(
|
||||
_ => Some(Some(tool_choice.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_openai_cli_request_to_openai_chat_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_openai_cli_text_and_passthrough_fields_when_normalizing_to_chat() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"max_output_tokens": 128,
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hi"}]
|
||||
}],
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}}
|
||||
},
|
||||
"verbosity": "high"
|
||||
},
|
||||
"prompt_cache_key": "cache-key-456",
|
||||
"prompt_cache_retention": "persist",
|
||||
"service_tier": "flex",
|
||||
"user": "user-456",
|
||||
"safety_identifier": "safe-456",
|
||||
"top_logprobs": 4
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["max_completion_tokens"], 128);
|
||||
assert_eq!(
|
||||
converted["response_format"],
|
||||
json!({
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}}
|
||||
})
|
||||
);
|
||||
assert_eq!(converted["verbosity"], "high");
|
||||
assert_eq!(converted["prompt_cache_key"], "cache-key-456");
|
||||
assert_eq!(converted["prompt_cache_retention"], "persist");
|
||||
assert_eq!(converted["service_tier"], "flex");
|
||||
assert_eq!(converted["user"], "user-456");
|
||||
assert_eq!(converted["safety_identifier"], "safe-456");
|
||||
assert_eq!(converted["top_logprobs"], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_assistant_refusal_when_normalizing_to_chat() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "refusal", "refusal": "cannot comply"}]
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||
assert_eq!(converted["messages"][0]["refusal"], "cannot comply");
|
||||
assert_eq!(converted["messages"][0]["content"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_stream_options() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"input": "hello"
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["stream"], true);
|
||||
assert_eq!(converted["stream_options"]["include_usage"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_stream_options_without_forcing_include_usage_during_normalization() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": false,
|
||||
"extra": "keep-me"
|
||||
},
|
||||
"input": "hello"
|
||||
});
|
||||
|
||||
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
|
||||
.expect("responses request should normalize to chat");
|
||||
|
||||
assert_eq!(converted["stream_options"]["include_usage"], false);
|
||||
assert_eq!(converted["stream_options"]["extra"], "keep-me");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ pub fn convert_openai_chat_response_to_claude_chat(
|
||||
let message = first_choice.get("message")?.as_object()?;
|
||||
let mut content = Vec::new();
|
||||
|
||||
if let Some(reasoning_content) = message.get("reasoning_content").and_then(Value::as_str) {
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
content.push(json!({
|
||||
"type": "thinking",
|
||||
"thinking": reasoning_content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
|
||||
if !text.trim().is_empty() {
|
||||
content.push(json!({
|
||||
@@ -92,4 +100,62 @@ pub fn convert_openai_chat_response_to_claude_chat(
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}))
|
||||
.map(|mut response| {
|
||||
if let Some(cached_tokens) = usage
|
||||
.and_then(|value| value.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("cached_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
response["usage"]["cache_read_input_tokens"] = Value::from(cached_tokens);
|
||||
}
|
||||
if let Some(cached_creation_tokens) = usage
|
||||
.and_then(|value| value.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("cached_creation_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
response["usage"]["cache_creation_input_tokens"] = Value::from(cached_creation_tokens);
|
||||
}
|
||||
response
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_response_to_claude_chat;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_openai_reasoning_and_cache_usage_in_claude_response() {
|
||||
let response = json!({
|
||||
"id": "chatcmpl_123",
|
||||
"model": "gpt-5.4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "hello",
|
||||
"reasoning_content": "step by step"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 11,
|
||||
"completion_tokens": 7,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 3,
|
||||
"cached_creation_tokens": 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_response_to_claude_chat(&response, &json!({}))
|
||||
.expect("response should convert");
|
||||
|
||||
assert_eq!(converted["content"][0]["type"], "thinking");
|
||||
assert_eq!(converted["content"][0]["thinking"], "step by step");
|
||||
assert_eq!(converted["usage"]["cache_read_input_tokens"], 3);
|
||||
assert_eq!(converted["usage"]["cache_creation_input_tokens"], 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,43 +10,72 @@ pub fn convert_openai_chat_response_to_gemini_chat(
|
||||
) -> 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();
|
||||
let mut candidates = Vec::new();
|
||||
for choice in choices {
|
||||
let choice = choice.as_object()?;
|
||||
let message = 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(reasoning_content) = message.get("reasoning_content").and_then(Value::as_str) {
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
parts.push(json!({
|
||||
"text": reasoning_content,
|
||||
"thought": true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 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": "" }));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
parts.push(json!({ "text": "" }));
|
||||
}
|
||||
|
||||
let mut finish_reason = match choice.get("finish_reason").and_then(Value::as_str) {
|
||||
Some("stop") | None => "STOP",
|
||||
Some("length") => "MAX_TOKENS",
|
||||
Some("content_filter") => "SAFETY",
|
||||
Some("tool_calls") | Some("function_call") => "STOP",
|
||||
Some(other) => other,
|
||||
};
|
||||
if parts.iter().any(|part| part.get("functionCall").is_some()) {
|
||||
finish_reason = "STOP";
|
||||
}
|
||||
candidates.push(json!({
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
},
|
||||
"finishReason": finish_reason,
|
||||
"index": choice.get("index").and_then(Value::as_u64).unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("prompt_tokens"))
|
||||
@@ -56,20 +85,17 @@ pub fn convert_openai_chat_response_to_gemini_chat(
|
||||
.and_then(|value| value.get("completion_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.and_then(|value| value.get("completion_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("reasoning_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let visible_completion_tokens = completion_tokens.saturating_sub(reasoning_tokens);
|
||||
let total_tokens = usage
|
||||
.and_then(|value| value.get("total_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(prompt_tokens + completion_tokens);
|
||||
let 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)
|
||||
@@ -84,18 +110,82 @@ pub fn convert_openai_chat_response_to_gemini_chat(
|
||||
Some(json!({
|
||||
"responseId": response_id,
|
||||
"modelVersion": model,
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
},
|
||||
"finishReason": finish_reason,
|
||||
"index": 0,
|
||||
}],
|
||||
"candidates": candidates,
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": prompt_tokens,
|
||||
"candidatesTokenCount": completion_tokens,
|
||||
"candidatesTokenCount": visible_completion_tokens,
|
||||
"thoughtsTokenCount": reasoning_tokens,
|
||||
"totalTokenCount": total_tokens,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_response_to_gemini_chat;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_multiple_openai_choices_and_reasoning_tokens_for_gemini() {
|
||||
let response = json!({
|
||||
"id": "chatcmpl_123",
|
||||
"model": "gpt-5.4",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "hello",
|
||||
"reasoning_content": "step by step"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"arguments": "{\"city\":\"Shanghai\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 7,
|
||||
"total_tokens": 17,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_response_to_gemini_chat(&response, &json!({}))
|
||||
.expect("response should convert");
|
||||
|
||||
assert_eq!(
|
||||
converted["candidates"]
|
||||
.as_array()
|
||||
.expect("candidates")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
converted["candidates"][0]["content"]["parts"][0]["thought"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
converted["candidates"][1]["content"]["parts"][0]["functionCall"]["name"],
|
||||
"lookup"
|
||||
);
|
||||
assert_eq!(converted["usageMetadata"]["candidatesTokenCount"], 5);
|
||||
assert_eq!(converted["usageMetadata"]["thoughtsTokenCount"], 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,32 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
let message = first_choice.get("message")?.as_object()?;
|
||||
let mut message_content = Vec::new();
|
||||
let mut reasoning_summaries = Vec::new();
|
||||
let message_annotations = message.get("annotations").cloned();
|
||||
match message.get("content") {
|
||||
Some(Value::String(value)) => {
|
||||
if !value.is_empty() {
|
||||
message_content.push(json!({
|
||||
let mut item = json!({
|
||||
"type": "output_text",
|
||||
"text": value,
|
||||
"annotations": []
|
||||
}));
|
||||
});
|
||||
if let Some(annotations) = message_annotations.clone() {
|
||||
item["annotations"] = annotations;
|
||||
}
|
||||
message_content.push(item);
|
||||
}
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
let text_part_count = parts
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.filter(|part| {
|
||||
matches!(
|
||||
part.get("type").and_then(Value::as_str).unwrap_or_default(),
|
||||
"text" | "output_text"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
for part in parts {
|
||||
let part = part.as_object()?;
|
||||
let part_type = part
|
||||
@@ -36,11 +51,17 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
.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!({
|
||||
let mut item = json!({
|
||||
"type": "output_text",
|
||||
"text": piece,
|
||||
"annotations": []
|
||||
}));
|
||||
});
|
||||
if text_part_count == 1 {
|
||||
if let Some(annotations) = message_annotations.clone() {
|
||||
item["annotations"] = annotations;
|
||||
}
|
||||
}
|
||||
message_content.push(item);
|
||||
}
|
||||
} else if matches!(part_type.as_str(), "image_url" | "output_image") {
|
||||
if let Some(image_url) = part
|
||||
@@ -82,6 +103,14 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
Some(Value::Null) | None => {}
|
||||
_ => return None,
|
||||
}
|
||||
if let Some(refusal) = message.get("refusal").and_then(Value::as_str) {
|
||||
if !refusal.trim().is_empty() {
|
||||
message_content.push(json!({
|
||||
"type": "refusal",
|
||||
"refusal": refusal,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if let Some(reasoning_content) = message.get("reasoning_content").and_then(Value::as_str) {
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
reasoning_summaries.push(reasoning_content.to_string());
|
||||
@@ -139,7 +168,7 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
|
||||
Some(build_openai_cli_response_with_content(
|
||||
let mut response = build_openai_cli_response_with_content(
|
||||
&response_id,
|
||||
model,
|
||||
message_content,
|
||||
@@ -150,5 +179,146 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
))
|
||||
);
|
||||
|
||||
if let Some(created) = body.get("created").and_then(Value::as_i64).or_else(|| {
|
||||
body.get("created")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as i64)
|
||||
}) {
|
||||
response["created_at"] = Value::from(created);
|
||||
}
|
||||
if let Some(service_tier) = body.get("service_tier").cloned().or_else(|| {
|
||||
report_context
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|request| request.get("service_tier"))
|
||||
.cloned()
|
||||
}) {
|
||||
response["service_tier"] = service_tier;
|
||||
}
|
||||
if let Some(request_object) = report_context
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
for key in [
|
||||
"instructions",
|
||||
"max_output_tokens",
|
||||
"parallel_tool_calls",
|
||||
"previous_response_id",
|
||||
"reasoning",
|
||||
"store",
|
||||
"temperature",
|
||||
"text",
|
||||
"tool_choice",
|
||||
"tools",
|
||||
"top_p",
|
||||
"truncation",
|
||||
"user",
|
||||
"metadata",
|
||||
] {
|
||||
if let Some(value) = request_object.get(key) {
|
||||
response[key] = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(prompt_details) = usage
|
||||
.and_then(|value| value.get("prompt_tokens_details"))
|
||||
.cloned()
|
||||
{
|
||||
response["usage"]["input_tokens_details"] = prompt_details;
|
||||
}
|
||||
if let Some(completion_details) = usage
|
||||
.and_then(|value| value.get("completion_tokens_details"))
|
||||
.cloned()
|
||||
{
|
||||
response["usage"]["output_tokens_details"] = completion_details;
|
||||
}
|
||||
|
||||
Some(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_chat_response_to_openai_cli;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_created_refusal_request_echo_and_usage_details_when_converting_to_responses() {
|
||||
let response = json!({
|
||||
"id": "chatcmpl_123",
|
||||
"object": "chat.completion",
|
||||
"created": 1741569952i64,
|
||||
"model": "gpt-5",
|
||||
"service_tier": "default",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Hello",
|
||||
"refusal": "partial refusal",
|
||||
"annotations": [{"type": "url_citation", "start_index": 0, "end_index": 5}]
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 19,
|
||||
"completion_tokens": 10,
|
||||
"total_tokens": 29,
|
||||
"prompt_tokens_details": {"cached_tokens": 0},
|
||||
"completion_tokens_details": {"reasoning_tokens": 0}
|
||||
}
|
||||
});
|
||||
let report_context = json!({
|
||||
"original_request_body": {
|
||||
"instructions": "Be concise.",
|
||||
"max_output_tokens": 32,
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "medium"},
|
||||
"store": true,
|
||||
"temperature": 1.0,
|
||||
"text": {"format": {"type": "text"}},
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"top_p": 1.0,
|
||||
"truncation": "disabled",
|
||||
"user": null,
|
||||
"metadata": {}
|
||||
}
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_response_to_openai_cli(&response, &report_context, false)
|
||||
.expect("chat response should convert to responses");
|
||||
|
||||
assert_eq!(converted["created_at"], 1741569952i64);
|
||||
assert_eq!(converted["service_tier"], "default");
|
||||
assert_eq!(converted["instructions"], "Be concise.");
|
||||
assert_eq!(converted["max_output_tokens"], 32);
|
||||
assert_eq!(converted["parallel_tool_calls"], true);
|
||||
assert_eq!(converted["text"], json!({"format": {"type": "text"}}));
|
||||
assert_eq!(converted["top_p"], 1.0);
|
||||
assert_eq!(
|
||||
converted["output"][0]["content"],
|
||||
json!([
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Hello",
|
||||
"annotations": [{"type": "url_citation", "start_index": 0, "end_index": 5}]
|
||||
},
|
||||
{
|
||||
"type": "refusal",
|
||||
"refusal": "partial refusal"
|
||||
}
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
converted["usage"]["input_tokens_details"],
|
||||
json!({"cached_tokens": 0})
|
||||
);
|
||||
assert_eq!(
|
||||
converted["usage"]["output_tokens_details"],
|
||||
json!({"reasoning_tokens": 0})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ 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,
|
||||
build_openai_cli_response, build_openai_cli_response_with_reasoning,
|
||||
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
|
||||
convert_openai_chat_response_to_openai_cli, OpenAiCliResponseUsage,
|
||||
};
|
||||
pub use to_openai_chat::{
|
||||
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
|
||||
|
||||
@@ -109,4 +109,81 @@ pub fn convert_claude_chat_response_to_openai_chat(
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
}))
|
||||
.map(|mut response| {
|
||||
if let Some(service_tier) = report_context
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|request| request.get("service_tier"))
|
||||
.cloned()
|
||||
{
|
||||
response["service_tier"] = service_tier;
|
||||
}
|
||||
let mut prompt_details = Map::new();
|
||||
if let Some(cached_tokens) = usage
|
||||
.and_then(|value| value.get("cache_read_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
prompt_details.insert("cached_tokens".to_string(), Value::from(cached_tokens));
|
||||
}
|
||||
if let Some(cached_creation_tokens) = usage
|
||||
.and_then(|value| value.get("cache_creation_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
prompt_details.insert(
|
||||
"cached_creation_tokens".to_string(),
|
||||
Value::from(cached_creation_tokens),
|
||||
);
|
||||
}
|
||||
if !prompt_details.is_empty() {
|
||||
response["usage"]["prompt_tokens_details"] = Value::Object(prompt_details);
|
||||
}
|
||||
response
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_claude_chat_response_to_openai_chat;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_claude_reasoning_cache_usage_and_service_tier() {
|
||||
let response = json!({
|
||||
"id": "msg_123",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "step by step" },
|
||||
{ "type": "text", "text": "hello" }
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {
|
||||
"input_tokens": 11,
|
||||
"output_tokens": 7,
|
||||
"cache_read_input_tokens": 3,
|
||||
"cache_creation_input_tokens": 2
|
||||
}
|
||||
});
|
||||
let report_context = json!({
|
||||
"original_request_body": {
|
||||
"service_tier": "default"
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_claude_chat_response_to_openai_chat(&response, &report_context)
|
||||
.expect("response should convert");
|
||||
|
||||
assert_eq!(
|
||||
converted["choices"][0]["message"]["reasoning_content"],
|
||||
"step by step"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["usage"]["prompt_tokens_details"]["cached_tokens"],
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
converted["usage"]["prompt_tokens_details"]["cached_creation_tokens"],
|
||||
2
|
||||
);
|
||||
assert_eq!(converted["service_tier"], "default");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,78 +10,119 @@ pub fn convert_gemini_chat_response_to_openai_chat(
|
||||
) -> 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)
|
||||
let mut choices = Vec::new();
|
||||
for candidate in candidates {
|
||||
let candidate = candidate.as_object()?;
|
||||
let content = candidate.get("content")?.as_object()?;
|
||||
let parts = content.get("parts")?.as_array()?;
|
||||
let mut text = String::new();
|
||||
let mut content_parts = Vec::new();
|
||||
let mut reasoning_content = String::new();
|
||||
let mut 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)
|
||||
{
|
||||
reasoning_content.push_str(piece);
|
||||
} else {
|
||||
text.push_str(piece);
|
||||
let tool_name = function_call.get("name")?.as_str()?;
|
||||
let tool_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
||||
tool_calls.push(json!({
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
}));
|
||||
} else if let Some(rendered_text) = render_gemini_textual_part(part) {
|
||||
text.push_str(rendered_text.as_str());
|
||||
content_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": piece,
|
||||
"text": rendered_text,
|
||||
}));
|
||||
} 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;
|
||||
}
|
||||
} 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 mut finish_reason = match candidate.get("finishReason").and_then(Value::as_str) {
|
||||
Some("STOP") => Some("stop"),
|
||||
Some("MAX_TOKENS") => Some("length"),
|
||||
Some(
|
||||
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" | "OTHER",
|
||||
) => Some("content_filter"),
|
||||
Some(other) if !other.is_empty() => Some(other),
|
||||
_ => None,
|
||||
};
|
||||
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
|
||||
finish_reason = Some("tool_calls");
|
||||
}
|
||||
let message_content = if content_parts.is_empty() && !tool_calls.is_empty() {
|
||||
Value::Null
|
||||
} else if has_non_text_content {
|
||||
Value::Array(content_parts)
|
||||
} else {
|
||||
Value::String(text)
|
||||
};
|
||||
let mut message = Map::new();
|
||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
message.insert("content".to_string(), message_content);
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
message.insert(
|
||||
"reasoning_content".to_string(),
|
||||
Value::String(reasoning_content),
|
||||
);
|
||||
}
|
||||
if !tool_calls.is_empty() {
|
||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
choices.push(json!({
|
||||
"index": candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
|
||||
"message": Value::Object(message),
|
||||
"finish_reason": finish_reason,
|
||||
}));
|
||||
}
|
||||
let usage = body.get("usageMetadata").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("promptTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.and_then(|value| value.get("thoughtsTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let completion_tokens = usage
|
||||
.and_then(|value| value.get("candidatesTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(0)
|
||||
+ reasoning_tokens;
|
||||
let total_tokens = usage
|
||||
.and_then(|value| value.get("totalTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
@@ -94,40 +135,107 @@ pub fn convert_gemini_chat_response_to_openai_chat(
|
||||
.unwrap_or("unknown");
|
||||
let id = body
|
||||
.get("responseId")
|
||||
.or_else(|| body.get("_v1internal_response_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatcmpl-local-finalize");
|
||||
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,
|
||||
}],
|
||||
"choices": choices,
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
}))
|
||||
.map(|mut response| {
|
||||
if reasoning_tokens > 0 {
|
||||
response["usage"]["completion_tokens_details"] =
|
||||
json!({ "reasoning_tokens": reasoning_tokens });
|
||||
}
|
||||
response
|
||||
})
|
||||
}
|
||||
|
||||
fn render_gemini_textual_part(part: &Map<String, Value>) -> Option<String> {
|
||||
if let Some(code) = part.get("executableCode").and_then(Value::as_object) {
|
||||
let language = code
|
||||
.get("language")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let source = code.get("code").and_then(Value::as_str).unwrap_or_default();
|
||||
return Some(format!("```{language}\n{source}\n```"));
|
||||
}
|
||||
if let Some(result) = part.get("codeExecutionResult").and_then(Value::as_object) {
|
||||
let output = result
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
return Some(format!("```output\n{output}\n```"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_gemini_chat_response_to_openai_chat;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_gemini_candidates_reasoning_and_code_execution() {
|
||||
let response = json!({
|
||||
"responseId": "resp_123",
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"candidates": [
|
||||
{
|
||||
"index": 0,
|
||||
"finishReason": "RECITATION",
|
||||
"content": {
|
||||
"parts": [
|
||||
{ "text": "thinking", "thought": true },
|
||||
{ "executableCode": { "language": "python", "code": "print(1)" } },
|
||||
{ "codeExecutionResult": { "output": "1" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"finishReason": "STOP",
|
||||
"content": {
|
||||
"parts": [
|
||||
{ "functionCall": { "id": "call_1", "name": "lookup", "args": { "city": "Shanghai" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5,
|
||||
"thoughtsTokenCount": 2,
|
||||
"totalTokenCount": 17
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_gemini_chat_response_to_openai_chat(&response, &json!({}))
|
||||
.expect("response should convert");
|
||||
|
||||
assert_eq!(converted["choices"].as_array().expect("choices").len(), 2);
|
||||
assert_eq!(converted["choices"][0]["finish_reason"], "content_filter");
|
||||
assert_eq!(
|
||||
converted["choices"][0]["message"]["reasoning_content"],
|
||||
"thinking"
|
||||
);
|
||||
let content = converted["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.expect("content should be string");
|
||||
assert!(content.contains("print(1)"));
|
||||
assert!(content.contains("```output\n1\n```"));
|
||||
assert_eq!(converted["choices"][1]["finish_reason"], "tool_calls");
|
||||
assert_eq!(
|
||||
converted["usage"]["completion_tokens_details"]["reasoning_tokens"],
|
||||
2
|
||||
);
|
||||
assert_eq!(converted["usage"]["completion_tokens"], 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ pub fn convert_openai_cli_response_to_openai_chat(
|
||||
let mut content_parts = Vec::new();
|
||||
let mut reasoning_content = String::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut annotations = Vec::new();
|
||||
let mut refusal = Vec::new();
|
||||
let mut has_non_text_content = false;
|
||||
|
||||
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
|
||||
@@ -36,12 +38,33 @@ pub fn convert_openai_cli_response_to_openai_chat(
|
||||
if matches!(part_type.as_str(), "output_text" | "text") {
|
||||
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
|
||||
{
|
||||
let annotation_offset = text.chars().count() as i64;
|
||||
if let Some(raw_annotations) =
|
||||
part_object.get("annotations").and_then(Value::as_array)
|
||||
{
|
||||
annotations.extend(raw_annotations.iter().map(
|
||||
|annotation| {
|
||||
offset_annotation_indices(
|
||||
annotation,
|
||||
annotation_offset,
|
||||
)
|
||||
},
|
||||
));
|
||||
}
|
||||
text.push_str(piece);
|
||||
content_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": piece,
|
||||
}));
|
||||
}
|
||||
} else if part_type == "refusal" {
|
||||
if let Some(piece) =
|
||||
part_object.get("refusal").and_then(Value::as_str)
|
||||
{
|
||||
if !piece.trim().is_empty() {
|
||||
refusal.push(piece.to_string());
|
||||
}
|
||||
}
|
||||
} else if matches!(part_type.as_str(), "output_image" | "image_url") {
|
||||
if let Some((image_url, detail)) =
|
||||
extract_openai_response_image(part_object)
|
||||
@@ -151,6 +174,18 @@ pub fn convert_openai_cli_response_to_openai_chat(
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatcmpl-local-openai-cli");
|
||||
let created = body.get("created_at").and_then(Value::as_i64).or_else(|| {
|
||||
body.get("created_at")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as i64)
|
||||
});
|
||||
let service_tier = body.get("service_tier").cloned().or_else(|| {
|
||||
report_context
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|request| request.get("service_tier"))
|
||||
.cloned()
|
||||
});
|
||||
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
@@ -181,11 +216,17 @@ pub fn convert_openai_cli_response_to_openai_chat(
|
||||
Value::String(reasoning_content),
|
||||
);
|
||||
}
|
||||
if !refusal.is_empty() {
|
||||
message.insert("refusal".to_string(), Value::String(refusal.join("\n")));
|
||||
}
|
||||
if !annotations.is_empty() {
|
||||
message.insert("annotations".to_string(), Value::Array(annotations));
|
||||
}
|
||||
if !tool_calls.is_empty() {
|
||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
let mut response = json!({
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
@@ -199,7 +240,27 @@ pub fn convert_openai_cli_response_to_openai_chat(
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
}))
|
||||
});
|
||||
if let Some(created) = created {
|
||||
response["created"] = Value::from(created);
|
||||
}
|
||||
if let Some(service_tier) = service_tier {
|
||||
response["service_tier"] = service_tier;
|
||||
}
|
||||
if let Some(input_details) = usage
|
||||
.and_then(|value| value.get("input_tokens_details"))
|
||||
.cloned()
|
||||
{
|
||||
response["usage"]["prompt_tokens_details"] = input_details;
|
||||
}
|
||||
if let Some(output_details) = usage
|
||||
.and_then(|value| value.get("output_tokens_details"))
|
||||
.cloned()
|
||||
{
|
||||
response["usage"]["completion_tokens_details"] = output_details;
|
||||
}
|
||||
|
||||
Some(response)
|
||||
}
|
||||
|
||||
fn extract_openai_response_image(
|
||||
@@ -236,3 +297,81 @@ fn extract_openai_response_image(
|
||||
});
|
||||
Some((image_url, detail))
|
||||
}
|
||||
|
||||
fn offset_annotation_indices(annotation: &Value, offset: i64) -> Value {
|
||||
let Some(object) = annotation.as_object() else {
|
||||
return annotation.clone();
|
||||
};
|
||||
let mut adjusted = object.clone();
|
||||
for key in [
|
||||
"start_index",
|
||||
"end_index",
|
||||
"start_char",
|
||||
"end_char",
|
||||
"index",
|
||||
] {
|
||||
if let Some(value) = adjusted.get(key).and_then(Value::as_i64) {
|
||||
adjusted.insert(key.to_string(), Value::from(value + offset));
|
||||
}
|
||||
}
|
||||
Value::Object(adjusted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_openai_cli_response_to_openai_chat;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_created_refusal_annotations_and_usage_details_when_converting_to_chat() {
|
||||
let response = json!({
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"created_at": 1741476542i64,
|
||||
"model": "gpt-5",
|
||||
"service_tier": "flex",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Hello",
|
||||
"annotations": [{"type": "file_citation", "start_index": 0, "end_index": 5}]
|
||||
},
|
||||
{"type": "refusal", "refusal": "partial refusal"}
|
||||
]
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"input_tokens_details": {"cached_tokens": 2},
|
||||
"output_tokens": 4,
|
||||
"output_tokens_details": {"reasoning_tokens": 1},
|
||||
"total_tokens": 14
|
||||
}
|
||||
});
|
||||
|
||||
let converted = convert_openai_cli_response_to_openai_chat(&response, &json!({}))
|
||||
.expect("responses response should convert to chat");
|
||||
|
||||
assert_eq!(converted["created"], 1741476542i64);
|
||||
assert_eq!(converted["service_tier"], "flex");
|
||||
assert_eq!(converted["choices"][0]["message"]["content"], "Hello");
|
||||
assert_eq!(
|
||||
converted["choices"][0]["message"]["refusal"],
|
||||
"partial refusal"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["choices"][0]["message"]["annotations"],
|
||||
json!([{"type": "file_citation", "start_index": 0, "end_index": 5}])
|
||||
);
|
||||
assert_eq!(
|
||||
converted["usage"]["prompt_tokens_details"],
|
||||
json!({"cached_tokens": 2})
|
||||
);
|
||||
assert_eq!(
|
||||
converted["usage"]["completion_tokens_details"],
|
||||
json!({"reasoning_tokens": 1})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user