refactor: 抽离 AI pipeline 与调度共享能力逻辑

This commit is contained in:
fawney19
2026-04-10 01:46:14 +08:00
parent b901a6ffc7
commit 5014e2f5fd
255 changed files with 15057 additions and 3115 deletions

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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,
})
);
}
}

View File

@@ -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": {} }));
}
}

View File

@@ -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"
}])
);
}
}

View File

@@ -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());
}
}

View File

@@ -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": {} }
}
}
])
);
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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})
);
}
}

View File

@@ -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,

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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})
);
}
}