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

@@ -69,8 +69,9 @@ pub use crate::conversion::response::{
};
pub use crate::conversion::{
build_core_error_body_for_client_format, is_core_error_finalize_kind,
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, LocalCoreSyncErrorKind, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};

View File

@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
pub struct ExecutionRuntimeAuthContext {
pub user_id: String,
pub api_key_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_name: Option<String>,
pub balance_remaining: Option<f64>,
pub access_allowed: bool,
}

View File

@@ -271,6 +271,8 @@ mod tests {
Some(ExecutionRuntimeAuthContext {
user_id: "user-1".to_string(),
api_key_id: "key-1".to_string(),
username: None,
api_key_name: None,
balance_remaining: Some(12.5),
access_allowed: true,
}),

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

View File

@@ -145,6 +145,25 @@ impl ClaudeProviderState {
},
});
}
"thinking_delta" => {
let Some(piece) = delta
.get("thinking")
.and_then(Value::as_str)
.or_else(|| delta.get("text").and_then(Value::as_str))
else {
return Ok(out);
};
if piece.is_empty() {
return Ok(out);
}
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ReasoningDelta(piece.to_string()),
});
}
_ => {}
}
}
@@ -162,6 +181,26 @@ impl ClaudeProviderState {
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if block_type == "thinking" {
let Some(piece) = block
.get("thinking")
.and_then(Value::as_str)
.or_else(|| block.get("text").and_then(Value::as_str))
else {
return Ok(out);
};
if piece.is_empty() {
return Ok(out);
}
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ReasoningDelta(piece.to_string()),
});
return Ok(out);
}
if block_type == "text" {
let Some(text) = block.get("text").and_then(Value::as_str) else {
return Ok(out);
@@ -273,12 +312,21 @@ enum ClaudeOpenBlock {
Text {
block_index: usize,
},
Thinking {
block_index: usize,
},
Tool {
tool_index: usize,
block_index: usize,
},
}
#[derive(Default)]
struct ClaudeClientToolState {
call_id: String,
name: String,
}
#[derive(Default)]
pub struct ClaudeClientEmitter {
message_id: Option<String>,
@@ -288,6 +336,7 @@ pub struct ClaudeClientEmitter {
next_block_index: usize,
open_block: Option<ClaudeOpenBlock>,
tool_block_indices: BTreeMap<usize, usize>,
tool_states: BTreeMap<usize, ClaudeClientToolState>,
}
impl ClaudeClientEmitter {
@@ -313,6 +362,10 @@ impl ClaudeClientEmitter {
"content": [],
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
},
}
}),
)
@@ -324,6 +377,7 @@ impl ClaudeClientEmitter {
};
let block_index = match open_block {
ClaudeOpenBlock::Text { block_index } => block_index,
ClaudeOpenBlock::Thinking { block_index } => block_index,
ClaudeOpenBlock::Tool { block_index, .. } => block_index,
};
encode_json_sse(
@@ -358,6 +412,29 @@ impl ClaudeClientEmitter {
Ok(out)
}
fn ensure_thinking_block(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = Vec::new();
if let Some(ClaudeOpenBlock::Thinking { .. }) = self.open_block {
return Ok(out);
}
out.extend(self.close_open_block()?);
let block_index = self.next_block_index;
self.next_block_index += 1;
self.open_block = Some(ClaudeOpenBlock::Thinking { block_index });
out.extend(encode_json_sse(
Some("content_block_start"),
&json!({
"type": "content_block_start",
"index": block_index,
"content_block": {
"type": "thinking",
"thinking": "",
}
}),
)?);
Ok(out)
}
fn ensure_tool_block(
&mut self,
tool_index: usize,
@@ -429,19 +506,52 @@ impl ClaudeClientEmitter {
)?);
Ok(out)
}
CanonicalStreamEvent::ReasoningDelta(text) => {
let mut out = self.ensure_started()?;
out.extend(self.ensure_thinking_block()?);
let block_index = match self.open_block {
Some(ClaudeOpenBlock::Thinking { block_index }) => block_index,
_ => return Ok(out),
};
out.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": block_index,
"delta": {
"type": "thinking_delta",
"thinking": text,
}
}),
)?);
Ok(out)
}
CanonicalStreamEvent::ToolCallStart {
index,
call_id,
name,
} => {
let mut out = self.ensure_started()?;
let state = self.tool_states.entry(index).or_default();
state.call_id = call_id.clone();
state.name = name.clone();
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
Ok(out)
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let mut out = self.ensure_started()?;
let call_id = format!("tool_{index}");
out.extend(self.ensure_tool_block(index, &call_id, "unknown")?);
let state = self.tool_states.entry(index).or_default();
let call_id = if state.call_id.is_empty() {
format!("tool_{index}")
} else {
state.call_id.clone()
};
let name = if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
};
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
let block_index = match self.open_block {
Some(ClaudeOpenBlock::Tool { block_index, .. }) => block_index,
_ => return Ok(out),
@@ -482,15 +592,14 @@ impl ClaudeClientEmitter {
"stop_sequence": Value::Null,
}),
);
if let Some(usage) = usage {
payload.insert(
"usage".to_string(),
json!({
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}),
);
}
let usage = usage.unwrap_or_default();
payload.insert(
"usage".to_string(),
json!({
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}),
);
out.extend(encode_json_sse(
Some("message_delta"),
&Value::Object(payload),
@@ -524,3 +633,124 @@ impl ClaudeClientEmitter {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn data_line(value: Value) -> Vec<u8> {
format!("data: {}\n", value).into_bytes()
}
#[test]
fn claude_provider_state_parses_thinking_deltas() {
let mut state = ClaudeProviderState::default();
let report_context = json!({});
let _ = state
.push_line(
&report_context,
data_line(json!({
"type": "message_start",
"message": {
"id": "msg_123",
"model": "claude-sonnet-4-5"
}
})),
)
.expect("message_start should parse");
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "thinking_delta",
"thinking": "step by step"
}
})),
)
.expect("thinking delta should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ReasoningDelta(ref text) if text == "step by step"
)));
}
#[test]
fn claude_client_emitter_preserves_tool_identity_and_emits_thinking_blocks() {
let mut emitter = ClaudeClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::Start,
})
.expect("start should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ReasoningDelta("step by step".to_string()),
})
.expect("reasoning should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "toolu_1".to_string(),
name: "lookup".to_string(),
},
})
.expect("tool start should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: "{\"city\":\"Shanghai\"}".to_string(),
},
})
.expect("tool delta should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"type\":\"thinking\""));
assert!(sse.contains("\"type\":\"thinking_delta\""));
assert!(sse.contains("\"id\":\"toolu_1\""));
assert!(sse.contains("\"name\":\"lookup\""));
assert!(sse.contains("\"partial_json\":\"{\\\"city\\\":\\\"Shanghai\\\"}\""));
assert!(sse.contains("\"usage\":{\"input_tokens\":0,\"output_tokens\":0}"));
}
#[test]
fn claude_client_emitter_injects_default_usage_into_finish_events() {
let mut emitter = ClaudeClientEmitter::default();
let bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_456".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("stop".to_string()),
usage: None,
},
})
.expect("finish should encode");
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: message_start"));
assert!(sse.contains("event: message_delta"));
assert!(sse.contains("\"stop_reason\":\"end_turn\""));
assert!(sse.contains("\"usage\":{\"input_tokens\":0,\"output_tokens\":0}"));
}
}

View File

@@ -22,6 +22,7 @@ pub struct GeminiProviderState {
started: bool,
finished: bool,
text_parts: BTreeMap<usize, String>,
reasoning_parts: BTreeMap<usize, String>,
tool_calls: BTreeMap<usize, GeminiProviderToolState>,
}
@@ -97,8 +98,16 @@ impl GeminiProviderState {
let Some(part_object) = part.as_object() else {
continue;
};
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
let previous = self.text_parts.entry(index).or_default();
if let Some(text) = render_gemini_part_as_text(part_object) {
let is_reasoning = part_object
.get("thought")
.and_then(Value::as_bool)
.unwrap_or(false);
let previous = if is_reasoning {
self.reasoning_parts.entry(index).or_default()
} else {
self.text_parts.entry(index).or_default()
};
let delta = if text.starts_with(previous.as_str()) {
text[previous.len()..].to_string()
} else if previous.as_str() == text {
@@ -106,12 +115,16 @@ impl GeminiProviderState {
} else {
text.to_string()
};
*previous = text.to_string();
*previous = text;
if !delta.is_empty() {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::TextDelta(delta),
event: if is_reasoning {
CanonicalStreamEvent::ReasoningDelta(delta)
} else {
CanonicalStreamEvent::TextDelta(delta)
},
});
}
continue;
@@ -179,7 +192,8 @@ impl GeminiProviderState {
let mut finish_reason = normalize_openai_finish_reason(match finish_reason {
"STOP" => Some("stop"),
"MAX_TOKENS" => Some("length"),
"SAFETY" => Some("content_filter"),
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII"
| "OTHER" => Some("content_filter"),
other => Some(other),
});
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
@@ -332,6 +346,9 @@ impl GeminiClientEmitter {
CanonicalStreamEvent::TextDelta(text) => {
self.emit_candidate(vec![json!({ "text": text })], None, None)
}
CanonicalStreamEvent::ReasoningDelta(text) => {
self.emit_candidate(vec![json!({ "text": text, "thought": true })], None, None)
}
CanonicalStreamEvent::ToolCallStart {
index,
call_id,
@@ -399,3 +416,112 @@ impl GeminiClientEmitter {
Ok(out)
}
}
fn render_gemini_part_as_text(part: &Map<String, Value>) -> Option<String> {
if let Some(text) = part.get("text").and_then(Value::as_str) {
return Some(text.to_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::*;
use serde_json::json;
fn data_line(value: Value) -> Vec<u8> {
format!("data: {}\n", value).into_bytes()
}
#[test]
fn gemini_provider_state_parses_thoughts_code_and_content_filter_finish() {
let mut state = GeminiProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"responseId": "resp_123",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"finishReason": "RECITATION",
"content": {
"parts": [
{ "text": "reason", "thought": true },
{ "executableCode": { "language": "python", "code": "print(1)" } }
]
}
}],
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3
}
})),
)
.expect("chunk should parse");
assert!(matches!(frames[0].event, CanonicalStreamEvent::Start));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ReasoningDelta(ref text) if text == "reason"
)));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::TextDelta(ref text) if text == "```python\nprint(1)\n```"
)));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::Finish { ref finish_reason, .. }
if finish_reason.as_deref() == Some("content_filter")
)));
}
#[test]
fn gemini_client_emitter_marks_reasoning_parts_as_thoughts() {
let mut emitter = GeminiClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gemini-2.5-pro".to_string(),
event: CanonicalStreamEvent::ReasoningDelta("reason".to_string()),
})
.expect("reasoning should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gemini-2.5-pro".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("stop".to_string()),
usage: Some(CanonicalUsage {
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
}),
},
})
.expect("finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"thought\":true"));
assert!(sse.contains("\"finishReason\":\"STOP\""));
}
}

View File

@@ -11,6 +11,7 @@ pub struct CanonicalUsage {
pub enum CanonicalStreamEvent {
Start,
TextDelta(String),
ReasoningDelta(String),
ToolCallStart {
index: usize,
call_id: String,
@@ -216,3 +217,23 @@ pub fn build_openai_chat_finish_chunk(id: &str, model: &str, finish_reason: Opti
}]
})
}
pub fn build_openai_chat_usage_chunk(
id: &str,
model: &str,
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
) -> Value {
json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
})
}

View File

@@ -1,18 +1,21 @@
use serde_json::Value;
use crate::conversion::{build_core_error_body_for_client_format, LocalCoreSyncErrorKind};
use crate::finalize::sse::encode_json_sse;
use crate::finalize::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
use crate::finalize::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
use crate::finalize::standard::openai::stream::{
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
OpenAICliProviderState,
};
use crate::finalize::standard::stream_core::common::CanonicalStreamFrame;
use crate::finalize::standard::stream_core::common::{decode_json_data_line, CanonicalStreamFrame};
use crate::finalize::PipelineFinalizeError;
#[derive(Default)]
pub struct StreamingStandardFormatMatrix {
provider: Option<ProviderStreamParser>,
client: Option<ClientStreamEmitter>,
terminated: bool,
}
impl StreamingStandardFormatMatrix {
@@ -21,7 +24,14 @@ impl StreamingStandardFormatMatrix {
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, PipelineFinalizeError> {
if self.terminated {
return Ok(Vec::new());
}
self.ensure_initialized(report_context);
if let Some(error_body) = build_client_error_body_for_line(report_context, &line) {
self.terminated = true;
return self.emit_error(error_body);
}
let Some(provider) = self.provider.as_mut() else {
return Ok(Vec::new());
};
@@ -30,6 +40,9 @@ impl StreamingStandardFormatMatrix {
}
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, PipelineFinalizeError> {
if self.terminated {
return Ok(Vec::new());
}
self.ensure_initialized(report_context);
let Some(provider) = self.provider.as_mut() else {
return Ok(Vec::new());
@@ -77,6 +90,13 @@ impl StreamingStandardFormatMatrix {
}
Ok(out)
}
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, PipelineFinalizeError> {
let Some(client) = self.client.as_mut() else {
return Ok(Vec::new());
};
client.emit_error(error_body)
}
}
enum ProviderStreamParser {
@@ -158,4 +178,399 @@ impl ClientStreamEmitter {
ClientStreamEmitter::Gemini(state) => state.finish(),
}
}
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, PipelineFinalizeError> {
match self {
ClientStreamEmitter::OpenAICli(state) => state.emit_error(error_body),
ClientStreamEmitter::Claude(_) => {
let event = error_body.get("type").and_then(Value::as_str);
encode_json_sse(event, &error_body)
}
ClientStreamEmitter::OpenAIChat(_) | ClientStreamEmitter::Gemini(_) => {
encode_json_sse(None, &error_body)
}
}
}
}
fn build_client_error_body_for_line(report_context: &Value, line: &[u8]) -> Option<Value> {
let value = decode_json_data_line(line)?;
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let (message, code, kind) = parse_provider_error(&provider_api_format, &value)?;
build_core_error_body_for_client_format(&client_api_format, &message, code.as_deref(), kind)
}
fn parse_provider_error(
provider_api_format: &str,
payload: &Value,
) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
match provider_api_format {
"openai:chat" | "openai:cli" | "openai:compact" => parse_openai_error(payload),
"claude:chat" | "claude:cli" => parse_claude_error(payload),
"gemini:chat" | "gemini:cli" => parse_gemini_error(payload),
_ => None,
}
}
fn parse_openai_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
let error = payload.get("error")?.as_object()?;
let message = error.get("message").and_then(Value::as_str)?.to_string();
let code = error
.get("code")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let kind = match error
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"invalid_request_error" => LocalCoreSyncErrorKind::InvalidRequest,
"authentication_error" => LocalCoreSyncErrorKind::Authentication,
"permission_error" => LocalCoreSyncErrorKind::PermissionDenied,
"not_found_error" => LocalCoreSyncErrorKind::NotFound,
"rate_limit_error" => LocalCoreSyncErrorKind::RateLimit,
"context_length_exceeded" => LocalCoreSyncErrorKind::ContextLengthExceeded,
"overloaded_error" => LocalCoreSyncErrorKind::Overloaded,
_ => LocalCoreSyncErrorKind::ServerError,
};
Some((message, code, kind))
}
fn parse_claude_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
let error = payload.get("error")?.as_object()?;
let message = error.get("message").and_then(Value::as_str)?.to_string();
let code = error
.get("code")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let kind = match error
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"invalid_request_error" => LocalCoreSyncErrorKind::InvalidRequest,
"authentication_error" => LocalCoreSyncErrorKind::Authentication,
"permission_error" => LocalCoreSyncErrorKind::PermissionDenied,
"not_found_error" => LocalCoreSyncErrorKind::NotFound,
"rate_limit_error" => LocalCoreSyncErrorKind::RateLimit,
"overloaded_error" => LocalCoreSyncErrorKind::Overloaded,
_ => LocalCoreSyncErrorKind::ServerError,
};
Some((message, code, kind))
}
fn parse_gemini_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
let error = payload.get("error")?.as_object()?;
let message = error.get("message").and_then(Value::as_str)?.to_string();
let code = error.get("code").map(|value| match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
_ => String::new(),
});
let kind = match error
.get("status")
.and_then(Value::as_str)
.unwrap_or_default()
{
"INVALID_ARGUMENT" => LocalCoreSyncErrorKind::InvalidRequest,
"UNAUTHENTICATED" => LocalCoreSyncErrorKind::Authentication,
"PERMISSION_DENIED" => LocalCoreSyncErrorKind::PermissionDenied,
"NOT_FOUND" => LocalCoreSyncErrorKind::NotFound,
"RESOURCE_EXHAUSTED" => LocalCoreSyncErrorKind::RateLimit,
"UNAVAILABLE" => LocalCoreSyncErrorKind::Overloaded,
_ => LocalCoreSyncErrorKind::ServerError,
};
let code = code.filter(|value| !value.is_empty());
Some((message, code, kind))
}
#[cfg(test)]
mod tests {
use super::StreamingStandardFormatMatrix;
use serde_json::{json, Value};
fn report_context(provider_api_format: &str, client_api_format: &str) -> Value {
json!({
"provider_api_format": provider_api_format,
"client_api_format": client_api_format,
"mapped_model": "test-model",
})
}
fn data_line(value: Value) -> Vec<u8> {
format!("data: {}\n", value).into_bytes()
}
#[test]
fn transforms_provider_errors_to_openai_chat_error_bodies() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"type\":\"invalid_request_error\"",
"\"code\":\"invalid_request\"",
),
(
"claude:chat",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"rate_limit\"",
),
(
"gemini:cli",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"429\"",
),
];
for (provider_api_format, line, message, err_type, code) in cases {
let report_context = report_context(provider_api_format, "openai:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("data: {\"error\":"));
assert!(!sse.contains("event: "));
assert!(sse.contains(message));
assert!(sse.contains(err_type));
assert!(sse.contains(code));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_provider_errors_to_claude_error_events() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"type\":\"invalid_request_error\"",
"\"code\":\"invalid_request\"",
),
(
"claude:chat",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"rate_limit\"",
),
(
"gemini:cli",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"429\"",
),
];
for (provider_api_format, line, message, err_type, code) in cases {
let report_context = report_context(provider_api_format, "claude:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("event: error\n"));
assert!(sse.contains("data: {"));
assert!(sse.contains("\"type\":\"error\""));
assert!(sse.contains("\"error\":{"));
assert!(sse.contains(message));
assert!(sse.contains(err_type));
assert!(sse.contains(code));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_provider_errors_to_gemini_error_bodies() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"code\":400",
"\"status\":\"INVALID_ARGUMENT\"",
),
(
"claude:chat",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"code\":429",
"\"status\":\"RESOURCE_EXHAUSTED\"",
),
(
"gemini:cli",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"code\":429",
"\"status\":\"RESOURCE_EXHAUSTED\"",
),
];
for (provider_api_format, line, message, code, status) in cases {
let report_context = report_context(provider_api_format, "gemini:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("data: {\"error\":"));
assert!(!sse.contains("event: "));
assert!(sse.contains(message));
assert!(sse.contains(code));
assert!(sse.contains(status));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_provider_errors_to_openai_cli_failed_events() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"type\":\"invalid_request_error\"",
"\"code\":\"invalid_request\"",
),
(
"claude:chat",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"rate_limit\"",
),
(
"gemini:cli",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"429\"",
),
];
for (provider_api_format, line, message, err_type, code) in cases {
let report_context = report_context(provider_api_format, "openai:cli");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("event: response.failed\n"));
assert!(sse.contains("\"sequence_number\":1"));
assert!(sse.contains(message));
assert!(sse.contains(err_type));
assert!(sse.contains(code));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
}

View File

@@ -170,7 +170,7 @@ pub fn maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload
"gemini:chat" | "gemini:cli" => {
convert_gemini_chat_response_to_openai_chat(&provider_body_json, report_context)
}
"openai:cli" | "openai:compact" => {
"openai:cli" => {
convert_openai_cli_response_to_openai_chat(&provider_body_json, report_context)
}
_ => None,
@@ -211,7 +211,7 @@ pub fn maybe_build_openai_cli_cross_format_sync_product_from_normalized_payload(
.trim()
.to_ascii_lowercase();
if !is_openai_cli_family_api_format(&client_api_format)
if client_api_format != "openai:cli"
|| sync_cli_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
@@ -228,7 +228,7 @@ pub fn maybe_build_openai_cli_cross_format_sync_product_from_normalized_payload(
};
let Some(client_body_json) = (match provider_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(provider_body_json.clone()),
"openai:cli" => Some(provider_body_json.clone()),
"claude:chat" | "claude:cli" => {
convert_claude_cli_response_to_openai_cli(&provider_body_json, report_context)
}
@@ -351,7 +351,12 @@ fn maybe_build_standard_same_format_sync_body(
return None;
}
body_json.cloned()
let body_json = body_json?;
if is_error_like_sync_body(body_json) {
return None;
}
Some(body_json.clone())
}
fn maybe_build_standard_same_format_stream_sync_body(
@@ -429,14 +434,25 @@ fn maybe_build_openai_cli_same_family_sync_body(
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !is_openai_cli_family_api_format(&provider_api_format)
|| !is_openai_cli_family_api_format(&client_api_format)
|| provider_api_format != client_api_format
|| needs_conversion
{
return None;
}
body_json.cloned()
let body_json = body_json?;
if is_error_like_sync_body(body_json) {
return None;
}
Some(body_json.clone())
}
fn maybe_build_openai_cli_same_family_stream_sync_body(
@@ -472,7 +488,8 @@ fn maybe_build_openai_cli_same_family_stream_sync_body(
if !is_openai_cli_family_api_format(&provider_api_format)
|| !is_openai_cli_family_api_format(&client_api_format)
|| (provider_api_format == client_api_format && needs_conversion)
|| provider_api_format != client_api_format
|| needs_conversion
{
return Ok(None);
}
@@ -507,6 +524,32 @@ fn maybe_build_openai_cross_format_provider_body_from_normalized_payload(
Ok(aggregated_stream_body.or_else(|| body_json.cloned()))
}
fn is_error_like_sync_body(value: &Value) -> bool {
let Some(object) = value.as_object() else {
return false;
};
object.contains_key("error")
|| object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "error")
|| object
.get("chunks")
.and_then(Value::as_array)
.is_some_and(|chunks| {
chunks.iter().any(|chunk| {
chunk.as_object().is_some_and(|chunk_object| {
chunk_object.contains_key("error")
|| chunk_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "error")
})
})
})
}
pub fn maybe_build_standard_cross_format_sync_product(
report_kind: &str,
provider_api_format: &str,
@@ -1402,6 +1445,33 @@ mod tests {
assert!(body_json.is_none());
}
#[test]
fn rejects_standard_same_format_error_body_json() {
let report_context = json!({
"provider_api_format": "claude:chat",
"client_api_format": "claude:chat",
"needs_conversion": false,
});
let provider_body_json = json!({
"type": "error",
"error": {
"type": "rate_limit_error",
"message": "slow down"
}
});
let body_json = maybe_build_standard_same_format_sync_body_from_normalized_payload(
"claude_chat_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
None,
)
.expect("same-format error guard should not error");
assert!(body_json.is_none());
}
#[test]
fn builds_openai_cli_same_family_body_from_stream_payload() {
let body = concat!(
@@ -1412,12 +1482,12 @@ mod tests {
);
let report_context = json!({
"provider_api_format": "openai:compact",
"client_api_format": "openai:cli",
"needs_conversion": true,
"client_api_format": "openai:compact",
"needs_conversion": false,
});
let body_json = maybe_build_openai_cli_same_family_sync_body_from_normalized_payload(
"openai_cli_sync_finalize",
"openai_compact_sync_finalize",
200,
Some(&report_context),
None,
@@ -1458,7 +1528,7 @@ mod tests {
fn falls_back_to_body_json_for_openai_cli_same_family_sync_payload() {
let report_context = json!({
"provider_api_format": "openai:compact",
"client_api_format": "openai:cli",
"client_api_format": "openai:compact",
"needs_conversion": false,
});
let provider_body_json = json!({
@@ -1481,6 +1551,32 @@ mod tests {
assert_eq!(body_json, provider_body_json);
}
#[test]
fn rejects_openai_cli_same_family_error_body_json() {
let report_context = json!({
"provider_api_format": "openai:cli",
"client_api_format": "openai:cli",
"needs_conversion": false,
});
let provider_body_json = json!({
"error": {
"message": "quota reached",
"type": "rate_limit_error"
}
});
let body_json = maybe_build_openai_cli_same_family_sync_body_from_normalized_payload(
"openai_cli_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
None,
)
.expect("openai-cli same-family error guard should not error");
assert!(body_json.is_none());
}
#[test]
fn builds_openai_chat_cross_format_sync_product_from_claude_body_json() {
let report_context = json!({
@@ -1640,54 +1736,6 @@ mod tests {
);
}
#[test]
fn builds_openai_compact_cross_format_sync_product_for_function_call_case() {
let report_context = json!({
"provider_api_format": "gemini:cli",
"client_api_format": "openai:compact",
"model": "gpt-5",
});
let provider_body_json = json!({
"responseId": "resp_cli_tool_123",
"candidates": [{
"content": {
"parts": [
{"text": "Need a tool."},
{"functionCall": {"name": "get_weather", "args": {"location": "Tokyo"}}}
],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}],
"modelVersion": "gemini-cli-upstream",
"usageMetadata": {
"promptTokenCount": 3,
"candidatesTokenCount": 5,
"thoughtsTokenCount": 2,
"totalTokenCount": 10
}
});
let product = maybe_build_openai_cli_cross_format_sync_product_from_normalized_payload(
"openai_compact_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
None,
)
.expect("openai-compact cross-format should succeed")
.expect("product should exist");
assert_eq!(product.provider_body_json, provider_body_json);
assert_eq!(product.client_body_json["object"], "response");
assert_eq!(
product.client_body_json["output"][1]["type"],
"function_call"
);
assert_eq!(product.client_body_json["output"][1]["name"], "get_weather");
}
#[test]
fn rejects_openai_chat_cross_format_for_unsupported_matrix() {
let report_context = json!({
@@ -1741,8 +1789,8 @@ mod tests {
fn standard_sync_finalize_product_handles_openai_cli_same_family_body() {
let report_context = json!({
"provider_api_format": "openai:compact",
"client_api_format": "openai:cli",
"needs_conversion": true,
"client_api_format": "openai:compact",
"needs_conversion": false,
});
let provider_body_json = json!({
"id": "resp_123",
@@ -1752,7 +1800,7 @@ mod tests {
});
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
"openai_cli_sync_finalize",
"openai_compact_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),

View File

@@ -13,7 +13,10 @@ use crate::conversion::request::{
normalize_openai_cli_request_to_openai_chat_request,
};
use super::codex::apply_codex_openai_cli_special_body_edits;
use super::{
codex::apply_codex_openai_cli_special_body_edits,
normalize::build_local_openai_chat_request_body,
};
#[allow(clippy::too_many_arguments)]
pub fn build_standard_request_body(
@@ -59,9 +62,11 @@ pub fn build_standard_request_body_from_canonical(
upstream_is_stream: bool,
) -> Option<Value> {
match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => {
build_openai_chat_request_body(canonical_request, mapped_model, upstream_is_stream)
}
"openai:chat" => build_local_openai_chat_request_body(
canonical_request,
mapped_model,
upstream_is_stream,
),
"openai:cli" => convert_openai_chat_request_to_openai_cli_request(
canonical_request,
mapped_model,
@@ -154,24 +159,6 @@ pub fn build_standard_upstream_url(
}
}
fn build_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let request_body_object = body_json.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
if upstream_is_stream {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
}
Some(Value::Object(provider_request_body))
}
#[cfg(test)]
mod tests {
use super::build_standard_request_body;
@@ -211,6 +198,37 @@ mod tests {
assert_eq!(converted["messages"][1]["content"], "Hello from Claude");
}
#[test]
fn builds_streaming_openai_chat_request_from_gemini_chat_source_with_include_usage() {
let request = json!({
"contents": [
{
"role": "user",
"parts": [{"text": "Hello from Gemini"}]
}
]
});
let converted = build_standard_request_body(
&request,
"gemini:chat",
"gpt-5",
"openai",
"openai:chat",
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
true,
None,
None,
)
.expect("gemini chat stream should convert to openai chat");
assert_eq!(converted["model"], "gpt-5");
assert_eq!(converted["stream"], true);
assert_eq!(converted["stream_options"]["include_usage"], true);
assert_eq!(converted["messages"][0]["role"], "user");
assert_eq!(converted["messages"][0]["content"], "Hello from Gemini");
}
#[test]
fn builds_claude_chat_request_from_gemini_chat_source() {
let request = json!({

View File

@@ -1,4 +1,4 @@
use serde_json::Value;
use serde_json::{json, Value};
use crate::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
@@ -21,6 +21,19 @@ pub fn build_local_openai_chat_request_body(
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
if upstream_is_stream {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
match provider_request_body.get_mut("stream_options") {
Some(Value::Object(stream_options)) => {
stream_options.insert("include_usage".to_string(), Value::Bool(true));
}
_ => {
provider_request_body.insert(
"stream_options".to_string(),
json!({
"include_usage": true,
}),
);
}
}
}
Some(Value::Object(provider_request_body))
}
@@ -51,9 +64,6 @@ pub fn build_cross_format_openai_chat_request_body(
false,
)
}
RequestConversionKind::ToOpenAICompact => {
convert_openai_chat_request_to_openai_cli_request(body_json, mapped_model, false, true)
}
_ => None,
}
}
@@ -86,6 +96,11 @@ pub fn build_cross_format_openai_cli_request_body(
let chat_like_request = normalize_openai_cli_request_to_openai_chat_request(body_json)?;
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
match conversion_kind {
RequestConversionKind::ToOpenAIChat => build_local_openai_chat_request_body(
&chat_like_request,
mapped_model,
upstream_is_stream,
),
RequestConversionKind::ToOpenAIFamilyCli => {
convert_openai_chat_request_to_openai_cli_request(
&chat_like_request,
@@ -94,14 +109,6 @@ pub fn build_cross_format_openai_cli_request_body(
false,
)
}
RequestConversionKind::ToOpenAICompact => {
convert_openai_chat_request_to_openai_cli_request(
&chat_like_request,
mapped_model,
false,
true,
)
}
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
&chat_like_request,
mapped_model,
@@ -112,17 +119,16 @@ pub fn build_cross_format_openai_cli_request_body(
mapped_model,
upstream_is_stream,
),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::build_cross_format_openai_cli_request_body;
use super::{build_cross_format_openai_cli_request_body, build_local_openai_chat_request_body};
use serde_json::json;
#[test]
fn builds_openai_family_cross_format_request_body_from_compact_source() {
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
let body_json = json!({
"model": "gpt-5",
"input": "hello",
@@ -131,14 +137,62 @@ mod tests {
let provider_request_body = build_cross_format_openai_cli_request_body(
&body_json,
"gpt-5-upstream",
"openai:compact",
"openai:cli",
"openai:chat",
false,
)
.expect("compact to openai cli body should build");
.expect("openai cli to openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["input"][0]["type"], "message");
assert_eq!(provider_request_body["input"][0]["role"], "user");
assert_eq!(provider_request_body["messages"][0]["role"], "user");
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
}
#[test]
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
let body_json = json!({
"model": "gpt-5",
"messages": [{
"role": "user",
"content": "hello"
}]
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
.expect("openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["stream"], true);
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
}
#[test]
fn streaming_local_openai_chat_request_body_preserves_stream_options_while_forcing_include_usage(
) {
let body_json = json!({
"model": "gpt-5",
"messages": [{
"role": "user",
"content": "hello"
}],
"stream_options": {
"include_usage": false,
"extra": "keep-me"
}
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
.expect("openai chat body should build");
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
assert_eq!(provider_request_body["stream_options"]["extra"], "keep-me");
}
}