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