refactor: 移除独立 hub/proxy/executor/gateway crate,统一为 gateway tunnel 架构

- 删除 aether-hub、aether-proxy 独立项目及其 Dockerfile/配置
- 删除 crates/aether-executor 和 crates/aether-gateway 全部模块
- 新增 apps/ 目录作为应用入口
- 将 hub 概念重构为 gateway tunnel transport
- 将 executor 重构为 execution runtime
- 新增 tunnel.rs 合约定义和 testkit tunnel/execution_runtime 模块
- 更新 Python 服务层和测试适配新架构命名
This commit is contained in:
fawney19
2026-04-03 14:59:58 +08:00
parent ddf18fed9a
commit 8f26e1a31f
983 changed files with 103098 additions and 105837 deletions

View File

@@ -0,0 +1,48 @@
[package]
name = "aether-gateway"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Rust ingress gateway for Aether phase 3a transparent proxy"
[dependencies]
aether-billing.workspace = true
aether-cache.workspace = true
aether-contracts.workspace = true
aether-crypto.workspace = true
aether-data.workspace = true
aether-http.workspace = true
aether-runtime.workspace = true
aether-wallet.workspace = true
async-stream.workspace = true
async-trait.workspace = true
axum = { version = "0.8", features = ["ws"] }
base64.workspace = true
bcrypt.workspace = true
bytes.workspace = true
chrono.workspace = true
chrono-tz.workspace = true
clap = { version = "4", features = ["derive", "env"] }
dashmap = "6"
flate2.workspace = true
futures-util.workspace = true
hmac.workspace = true
http.workspace = true
ldap3 = "0.11"
parking_lot = "0.12"
regex.workspace = true
redis.workspace = true
reqwest.workspace = true
rustls.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
url.workspace = true
uuid.workspace = true
webpki-roots.workspace = true

View File

@@ -0,0 +1,152 @@
use std::path::PathBuf;
use clap::Parser;
use tracing::info;
use aether_gateway::{serve_execution_runtime_tcp, serve_execution_runtime_unix};
use aether_runtime::{
init_service_runtime, DistributedConcurrencyGate, RedisDistributedConcurrencyConfig,
ServiceRuntimeConfig,
};
#[derive(Parser, Debug)]
#[command(
name = "execution-runtime-harness",
about = "Internal execution runtime harness for Aether tests"
)]
struct Args {
#[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_TRANSPORT",
default_value = "unix_socket"
)]
transport: String,
#[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_BIND",
default_value = "127.0.0.1:5219"
)]
bind: String,
#[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_UNIX_SOCKET",
default_value = "/tmp/aether-execution-runtime.sock"
)]
unix_socket: PathBuf,
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_MAX_IN_FLIGHT_REQUESTS")]
max_in_flight_requests: Option<usize>,
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LIMIT")]
distributed_request_limit: Option<usize>,
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL")]
distributed_request_redis_url: Option<String>,
#[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
)]
distributed_request_redis_key_prefix: Option<String>,
#[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
default_value_t = 30_000
)]
distributed_request_lease_ttl_ms: u64,
#[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
default_value_t = 10_000
)]
distributed_request_renew_interval_ms: u64,
#[arg(
long,
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
default_value_t = 1_000
)]
distributed_request_command_timeout_ms: u64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = rustls::crypto::ring::default_provider().install_default();
init_service_runtime(ServiceRuntimeConfig::new(
"aether-execution-runtime-harness",
"aether_gateway=info",
))?;
let args = Args::parse();
let distributed_request_gate = match args.distributed_request_limit.filter(|limit| *limit > 0) {
Some(limit) => {
let redis_url = args
.distributed_request_redis_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
)
})?;
Some(DistributedConcurrencyGate::new_redis(
"execution_runtime_requests_distributed",
limit,
RedisDistributedConcurrencyConfig {
url: redis_url.to_string(),
key_prefix: args
.distributed_request_redis_key_prefix
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
},
)?)
}
None => None,
};
match args.transport.trim().to_ascii_lowercase().as_str() {
"unix_socket" | "unix" | "uds" => {
info!(
socket = %args.unix_socket.display(),
"aether execution-runtime harness started"
);
serve_execution_runtime_unix(
&args.unix_socket,
args.max_in_flight_requests,
distributed_request_gate.clone(),
)
.await?;
}
"tcp" => {
info!(
bind = %args.bind,
max_in_flight_requests = args.max_in_flight_requests.unwrap_or_default(),
distributed_request_limit = args.distributed_request_limit.unwrap_or_default(),
"aether execution-runtime harness started"
);
serve_execution_runtime_tcp(
&args.bind,
args.max_in_flight_requests,
distributed_request_gate,
)
.await?;
}
other => {
return Err(format!("unsupported execution runtime transport: {other}").into());
}
}
Ok(())
}

View File

@@ -0,0 +1,161 @@
use std::net::SocketAddr;
use std::time::Duration;
use aether_gateway::{
build_tunnel_runtime_router_with_state, TunnelConnConfig, TunnelControlPlaneClient,
TunnelRuntimeState,
};
use aether_runtime::{
init_service_runtime, DistributedConcurrencyGate, RedisDistributedConcurrencyConfig,
ServiceRuntimeConfig,
};
use clap::Parser;
use tracing::info;
#[derive(Parser, Debug)]
#[command(
name = "aether-tunnel-runtime-harness",
about = "Standalone tunnel relay harness backed by aether-gateway tunnel runtime"
)]
struct Args {
#[arg(
long,
default_value = "0.0.0.0:8085",
env = "AETHER_TUNNEL_STANDALONE_BIND"
)]
bind: String,
#[arg(
long,
default_value_t = 0,
env = "AETHER_TUNNEL_STANDALONE_PROXY_IDLE_TIMEOUT"
)]
proxy_idle_timeout: u64,
#[arg(
long,
default_value_t = 15,
env = "AETHER_TUNNEL_STANDALONE_PING_INTERVAL"
)]
ping_interval: u64,
#[arg(
long,
default_value_t = 2048,
env = "AETHER_TUNNEL_STANDALONE_MAX_STREAMS"
)]
max_streams: usize,
#[arg(
long,
default_value_t = 128,
env = "AETHER_TUNNEL_STANDALONE_OUTBOUND_QUEUE_CAPACITY"
)]
outbound_queue_capacity: usize,
#[arg(
long,
default_value = "http://127.0.0.1:8084",
env = "AETHER_TUNNEL_STANDALONE_APP_BASE_URL"
)]
app_base_url: String,
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_MAX_IN_FLIGHT_REQUESTS")]
max_in_flight_requests: Option<usize>,
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LIMIT")]
distributed_request_limit: Option<usize>,
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL")]
distributed_request_redis_url: Option<String>,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
)]
distributed_request_redis_key_prefix: Option<String>,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
default_value_t = 30_000
)]
distributed_request_lease_ttl_ms: u64,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
default_value_t = 10_000
)]
distributed_request_renew_interval_ms: u64,
#[arg(
long,
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
default_value_t = 1_000
)]
distributed_request_command_timeout_ms: u64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_service_runtime(ServiceRuntimeConfig::new(
"aether-tunnel-standalone",
"aether_gateway=info",
))?;
let args = Args::parse();
let outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
let ping_interval = Duration::from_secs(args.ping_interval);
let mut state = TunnelRuntimeState::new(
TunnelControlPlaneClient::new(args.app_base_url),
TunnelConnConfig {
ping_interval,
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
outbound_queue_capacity,
},
args.max_streams,
)
.with_request_concurrency_limit(args.max_in_flight_requests);
if let Some(limit) = args.distributed_request_limit.filter(|limit| *limit > 0) {
let redis_url = args
.distributed_request_redis_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
)
})?;
state = state.with_distributed_request_gate(DistributedConcurrencyGate::new_redis(
"tunnel_requests_distributed",
limit,
RedisDistributedConcurrencyConfig {
url: redis_url.to_string(),
key_prefix: args
.distributed_request_redis_key_prefix
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
},
)?);
}
let app = build_tunnel_runtime_router_with_state(state);
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
info!(bind = %args.bind, "tunnel runtime harness started");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}

View File

@@ -0,0 +1,165 @@
use serde_json::{Map, Value};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum LocalCoreSyncErrorKind {
InvalidRequest,
Authentication,
PermissionDenied,
NotFound,
RateLimit,
ContextLengthExceeded,
Overloaded,
ServerError,
}
pub(crate) fn is_core_error_finalize_kind(report_kind: &str) -> bool {
core_error_default_client_api_format(report_kind).is_some()
}
pub(crate) fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
match report_kind {
"openai_chat_sync_finalize" => Some("openai:chat"),
"claude_chat_sync_finalize" => Some("claude:chat"),
"gemini_chat_sync_finalize" => Some("gemini:chat"),
"openai_cli_sync_finalize" => Some("openai:cli"),
"openai_compact_sync_finalize" => Some("openai:compact"),
"claude_cli_sync_finalize" => Some("claude:cli"),
"gemini_cli_sync_finalize" => Some("gemini:cli"),
_ => None,
}
}
pub(crate) fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
match report_kind {
"openai_chat_sync_finalize" => Some("openai_chat_sync_error"),
"claude_chat_sync_finalize" => Some("claude_chat_sync_error"),
"gemini_chat_sync_finalize" => Some("gemini_chat_sync_error"),
"openai_cli_sync_finalize" => Some("openai_cli_sync_error"),
"openai_compact_sync_finalize" => Some("openai_compact_sync_error"),
"claude_cli_sync_finalize" => Some("claude_cli_sync_error"),
"gemini_cli_sync_finalize" => Some("gemini_cli_sync_error"),
_ => None,
}
}
#[cfg(test)]
pub(crate) fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
match report_kind {
"openai_chat_sync_finalize" => Some("openai_chat_sync_success"),
"claude_chat_sync_finalize" => Some("claude_chat_sync_success"),
"gemini_chat_sync_finalize" => Some("gemini_chat_sync_success"),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize" => {
Some("openai_cli_sync_success")
}
"claude_cli_sync_finalize" => Some("claude_cli_sync_success"),
"gemini_cli_sync_finalize" => Some("gemini_cli_sync_success"),
_ => None,
}
}
pub(crate) fn build_core_error_body_for_client_format(
client_api_format: &str,
message: &str,
code: Option<&str>,
kind: LocalCoreSyncErrorKind,
) -> Option<Value> {
let mut error_object = Map::new();
error_object.insert("message".to_string(), Value::String(message.to_string()));
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" | "openai:cli" | "openai:compact" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(error_object),
)])))
}
"claude:chat" | "claude:cli" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_claude_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([
("type".to_string(), Value::String("error".to_string())),
("error".to_string(), Value::Object(error_object)),
])))
}
"gemini:chat" | "gemini:cli" => Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(Map::from_iter([
(
"code".to_string(),
Value::from(map_local_sync_error_kind_to_gemini_code(kind)),
),
("message".to_string(), Value::String(message.to_string())),
(
"status".to_string(),
Value::String(map_local_sync_error_kind_to_gemini_status(kind).to_string()),
),
])),
)]))),
_ => None,
}
}
fn map_local_sync_error_kind_to_openai_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest => "invalid_request_error",
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::ContextLengthExceeded => "context_length_exceeded",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "server_error",
}
}
fn map_local_sync_error_kind_to_claude_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"invalid_request_error"
}
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "api_error",
}
}
fn map_local_sync_error_kind_to_gemini_code(kind: LocalCoreSyncErrorKind) -> u16 {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
400
}
LocalCoreSyncErrorKind::Authentication => 401,
LocalCoreSyncErrorKind::PermissionDenied => 403,
LocalCoreSyncErrorKind::NotFound => 404,
LocalCoreSyncErrorKind::RateLimit => 429,
LocalCoreSyncErrorKind::Overloaded => 503,
LocalCoreSyncErrorKind::ServerError => 500,
}
}
fn map_local_sync_error_kind_to_gemini_status(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"INVALID_ARGUMENT"
}
LocalCoreSyncErrorKind::Authentication => "UNAUTHENTICATED",
LocalCoreSyncErrorKind::PermissionDenied => "PERMISSION_DENIED",
LocalCoreSyncErrorKind::NotFound => "NOT_FOUND",
LocalCoreSyncErrorKind::RateLimit => "RESOURCE_EXHAUSTED",
LocalCoreSyncErrorKind::Overloaded => "UNAVAILABLE",
LocalCoreSyncErrorKind::ServerError => "INTERNAL",
}
}

View File

@@ -0,0 +1,17 @@
pub(crate) mod error;
pub(crate) mod registry;
pub(crate) mod request;
pub(crate) mod response;
#[cfg(test)]
pub(crate) use error::core_success_background_report_kind;
pub(crate) use error::{
build_core_error_body_for_client_format, core_error_background_report_kind,
core_error_default_client_api_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
pub(crate) use registry::{
request_conversion_direct_auth, request_conversion_kind,
request_conversion_transport_supported, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};

View File

@@ -0,0 +1,204 @@
use crate::gateway::provider_transport::{
resolve_local_gemini_auth, resolve_local_openai_chat_auth, resolve_local_standard_auth,
supports_local_gemini_transport_with_network, supports_local_openai_chat_transport,
supports_local_standard_transport_with_network, GatewayProviderTransportSnapshot,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RequestConversionKind {
ToOpenAIChat,
ToOpenAIFamilyCli,
ToOpenAICompact,
ToClaudeStandard,
ToGeminiStandard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncChatResponseConversionKind {
ToOpenAIChat,
ToClaudeChat,
ToGeminiChat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncCliResponseConversionKind {
ToOpenAIFamilyCli,
ToClaudeCli,
ToGeminiCli,
}
pub(crate) fn request_conversion_kind(
client_api_format: &str,
provider_api_format: &str,
) -> Option<RequestConversionKind> {
let client_api_format = client_api_format.trim().to_ascii_lowercase();
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
if client_api_format == provider_api_format {
return None;
}
if !is_standard_api_format(client_api_format.as_str())
|| !is_standard_api_format(provider_api_format.as_str())
{
return None;
}
match provider_api_format.as_str() {
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
"openai:cli" => Some(RequestConversionKind::ToOpenAIFamilyCli),
"openai:compact" => Some(RequestConversionKind::ToOpenAICompact),
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
_ => None,
}
}
pub(crate) fn request_conversion_transport_supported(
transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind,
) -> bool {
match transport
.endpoint
.api_format
.trim()
.to_ascii_lowercase()
.as_str()
{
"openai:chat" => supports_local_openai_chat_transport(transport),
"openai:cli" => supports_local_standard_transport_with_network(transport, "openai:cli"),
"openai:compact" => {
supports_local_standard_transport_with_network(transport, "openai:compact")
}
"claude:chat" => supports_local_standard_transport_with_network(transport, "claude:chat"),
"claude:cli" => supports_local_standard_transport_with_network(transport, "claude:cli"),
"gemini:chat" => supports_local_gemini_transport_with_network(transport, "gemini:chat"),
"gemini:cli" => supports_local_gemini_transport_with_network(transport, "gemini:cli"),
_ => false,
}
}
pub(crate) fn request_conversion_direct_auth(
transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind,
) -> Option<(String, String)> {
match transport
.endpoint
.api_format
.trim()
.to_ascii_lowercase()
.as_str()
{
"openai:chat" => resolve_local_openai_chat_auth(transport),
"gemini:chat" | "gemini:cli" => resolve_local_gemini_auth(transport),
"openai:cli" | "openai:compact" | "claude:chat" | "claude:cli" => {
resolve_local_standard_auth(transport)
}
_ => None,
}
}
pub(crate) fn sync_chat_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncChatResponseConversionKind> {
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
match client_api_format.as_str() {
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
"gemini:chat" => Some(SyncChatResponseConversionKind::ToGeminiChat),
_ => None,
}
}
pub(crate) fn sync_cli_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncCliResponseConversionKind> {
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
match client_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
_ => None,
}
}
fn is_standard_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat"
| "openai:cli"
| "openai:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
request_conversion_kind("claude:chat", "openai:chat"),
Some(RequestConversionKind::ToOpenAIChat)
);
assert_eq!(
request_conversion_kind("gemini:chat", "claude:chat"),
Some(RequestConversionKind::ToClaudeStandard)
);
assert_eq!(
request_conversion_kind("gemini:cli", "openai:compact"),
Some(RequestConversionKind::ToOpenAICompact)
);
assert_eq!(
request_conversion_kind("openai:compact", "gemini:cli"),
Some(RequestConversionKind::ToGeminiStandard)
);
assert_eq!(request_conversion_kind("claude:chat", "claude:chat"), None);
}
#[test]
fn sync_response_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
sync_chat_response_conversion_kind("openai:chat", "claude:chat"),
Some(SyncChatResponseConversionKind::ToClaudeChat)
);
assert_eq!(
sync_chat_response_conversion_kind("claude:chat", "gemini:chat"),
Some(SyncChatResponseConversionKind::ToGeminiChat)
);
assert_eq!(
sync_chat_response_conversion_kind("gemini:chat", "openai:chat"),
Some(SyncChatResponseConversionKind::ToOpenAIChat)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:cli", "gemini:cli"),
Some(SyncCliResponseConversionKind::ToGeminiCli)
);
assert_eq!(
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
);
assert_eq!(
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
Some(SyncCliResponseConversionKind::ToClaudeCli)
);
}
}

View File

@@ -0,0 +1,363 @@
use serde_json::{json, Map, Value};
use uuid::Uuid;
use super::shared::parse_openai_tool_arguments;
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
use crate::gateway::ai_pipeline::planner::standard::{
copy_request_number_field, map_openai_reasoning_effort_to_claude_output,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens,
};
pub(crate) fn convert_openai_chat_request_to_claude_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut system_segments = Vec::new();
let mut messages = Vec::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
system_segments.push(text);
}
}
"user" => {
let blocks =
convert_openai_content_to_claude_blocks(message_object.get("content"), true)?;
if !blocks.is_empty() {
messages.push(build_claude_message("user", blocks));
}
}
"assistant" => {
let mut blocks = convert_openai_content_to_claude_blocks(
message_object.get("content"),
false,
)?;
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
let tool_input =
parse_openai_tool_arguments(function.get("arguments"))?;
blocks.push(json!({
"type": "tool_use",
"id": tool_call_id,
"name": tool_name,
"input": tool_input,
}));
}
}
if !blocks.is_empty() {
messages.push(build_claude_message("assistant", blocks));
}
}
"tool" => {
let tool_use_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_result =
parse_openai_tool_result_content(message_object.get("content"));
messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": tool_result,
"is_error": false,
}],
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
output.insert(
"messages".to_string(),
Value::Array(compact_claude_messages(messages)),
);
output.insert(
"max_tokens".to_string(),
Value::from(resolve_openai_chat_max_tokens(request)),
);
let system_text = system_segments
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
output.insert("system".to_string(), Value::String(system_text));
}
if upstream_is_stream {
output.insert("stream".to_string(), Value::Bool(true));
}
copy_request_number_field(request, &mut output, "temperature");
copy_request_number_field(request, &mut output, "top_p");
copy_request_number_field(request, &mut output, "top_k");
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
output.insert("stop_sequences".to_string(), Value::Array(stop_sequences));
}
if let Some(tools) = convert_openai_tools_to_claude(request.get("tools")) {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(request.get("tool_choice")) {
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(metadata) = request.get("metadata").cloned() {
output.insert("metadata".to_string(), metadata);
}
if let Some(reasoning_effort) = request.get("reasoning_effort").and_then(Value::as_str) {
if let Some(output_effort) = map_openai_reasoning_effort_to_claude_output(reasoning_effort)
{
output.insert(
"output_config".to_string(),
json!({ "effort": output_effort }),
);
}
}
Some(Value::Object(output))
}
fn convert_openai_content_to_claude_blocks(
content: Option<&Value>,
allow_images: bool,
) -> Option<Vec<Value>> {
match content {
None | Some(Value::Null) => Some(Vec::new()),
Some(Value::String(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({ "type": "text", "text": text })])
}
}
Some(Value::Array(parts)) => {
let mut blocks = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match part_type {
"text" | "input_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
blocks.push(json!({ "type": "text", "text": text }));
}
}
}
"image_url" | "input_image" if allow_images => {
let url = part_object
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.filter(|value| !value.trim().is_empty())?;
blocks.push(json!({
"type": "image",
"source": {
"type": "url",
"url": url,
}
}));
}
_ => {}
}
}
Some(blocks)
}
_ => None,
}
}
fn convert_openai_tools_to_claude(tools: Option<&Value>) -> Option<Vec<Value>> {
let tool_values = tools?.as_array()?;
let mut converted = Vec::new();
for tool in tool_values {
let tool_object = tool.as_object()?;
if tool_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value != "function")
{
continue;
}
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut converted_tool = Map::new();
converted_tool.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description").cloned() {
converted_tool.insert("description".to_string(), description);
}
converted_tool.insert(
"input_schema".to_string(),
function
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({})),
);
converted.push(Value::Object(converted_tool));
}
(!converted.is_empty()).then_some(converted)
}
fn convert_openai_tool_choice_to_claude(tool_choice: Option<&Value>) -> Option<Value> {
let tool_choice = tool_choice?;
match tool_choice {
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(json!({ "type": "none" })),
"required" => Some(json!({ "type": "any" })),
"auto" => Some(json!({ "type": "auto" })),
_ => None,
},
Value::Object(object) => {
let function_name = object
.get("function")
.and_then(Value::as_object)
.and_then(|function| function.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"type": "tool",
"name": function_name,
}))
}
_ => None,
}
}
fn compact_claude_messages(messages: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for message in messages {
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Some(last) = compact.last_mut() {
let last_role = last
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if last_role == role {
merge_claude_message_content(last, message);
continue;
}
}
compact.push(message);
}
if compact
.first()
.and_then(|value| value.get("role"))
.and_then(Value::as_str)
.is_some_and(|value| value == "assistant")
{
compact.insert(0, json!({ "role": "user", "content": "" }));
}
compact
}
fn merge_claude_message_content(target: &mut Value, message: Value) {
let Some(target_object) = target.as_object_mut() else {
return;
};
let incoming_content = message.get("content").cloned().unwrap_or(Value::Null);
let merged_blocks = extract_claude_content_blocks(target_object.get("content"))
.into_iter()
.chain(extract_claude_content_blocks(Some(&incoming_content)))
.collect::<Vec<_>>();
target_object.insert(
"content".to_string(),
simplify_claude_content(merged_blocks),
);
}
fn build_claude_message(role: &str, blocks: Vec<Value>) -> Value {
json!({
"role": role,
"content": simplify_claude_content(blocks),
})
}
fn simplify_claude_content(blocks: Vec<Value>) -> Value {
if blocks.is_empty() {
return Value::String(String::new());
}
let mut text_values = Vec::new();
for block in &blocks {
let Some(block_object) = block.as_object() else {
return Value::Array(blocks);
};
if block_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "text")
{
if let Some(text) = block_object.get("text").and_then(Value::as_str) {
text_values.push(text.to_string());
continue;
}
}
return Value::Array(blocks);
}
Value::String(text_values.join("\n"))
}
fn extract_claude_content_blocks(content: Option<&Value>) -> Vec<Value> {
match content {
Some(Value::String(text)) if !text.is_empty() => vec![json!({
"type": "text",
"text": text,
})],
Some(Value::Array(blocks)) => blocks.clone(),
_ => Vec::new(),
}
}

View File

@@ -0,0 +1,351 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use uuid::Uuid;
use super::shared::parse_openai_tool_arguments;
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
use crate::gateway::ai_pipeline::planner::standard::{
copy_request_number_field_as, map_openai_reasoning_effort_to_gemini_budget,
parse_openai_stop_sequences, value_as_u64,
};
pub(crate) fn convert_openai_chat_request_to_gemini_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut system_segments = Vec::new();
let mut tool_name_by_id = BTreeMap::new();
let mut contents = Vec::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
system_segments.push(text);
}
}
"user" => {
let parts =
convert_openai_content_to_gemini_parts(message_object.get("content"), true)?;
if !parts.is_empty() {
contents.push(json!({
"role": "user",
"parts": parts,
}));
}
}
"assistant" => {
let mut parts = convert_openai_content_to_gemini_parts(
message_object.get("content"),
false,
)?;
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
let tool_input =
parse_openai_tool_arguments(function.get("arguments"))?;
tool_name_by_id.insert(tool_call_id.clone(), tool_name.clone());
parts.push(json!({
"functionCall": {
"name": tool_name,
"args": tool_input,
"id": tool_call_id,
}
}));
}
}
if !parts.is_empty() {
contents.push(json!({
"role": "model",
"parts": parts,
}));
}
}
"tool" => {
let tool_use_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_name = tool_name_by_id
.get(&tool_use_id)
.cloned()
.unwrap_or_else(|| tool_use_id.clone());
let tool_result =
parse_openai_tool_result_content(message_object.get("content"));
contents.push(json!({
"role": "user",
"parts": [{
"functionResponse": {
"name": tool_name,
"id": tool_use_id,
"response": {
"result": tool_result,
},
}
}],
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
output.insert(
"contents".to_string(),
Value::Array(compact_gemini_contents(contents)),
);
if upstream_is_stream {
output.insert("stream".to_string(), Value::Bool(true));
}
let system_text = system_segments
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
output.insert(
"systemInstruction".to_string(),
json!({ "parts": [{ "text": system_text }] }),
);
}
let mut generation_config = Map::new();
if let Some(max_tokens) = request
.get("max_completion_tokens")
.and_then(value_as_u64)
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
{
generation_config.insert("maxOutputTokens".to_string(), Value::from(max_tokens));
}
copy_request_number_field_as(
request,
&mut generation_config,
"temperature",
"temperature",
);
copy_request_number_field_as(request, &mut generation_config, "top_p", "topP");
copy_request_number_field_as(request, &mut generation_config, "top_k", "topK");
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
generation_config.insert("stopSequences".to_string(), Value::Array(stop_sequences));
}
if let Some(reasoning_effort) = request.get("reasoning_effort").and_then(Value::as_str) {
if let Some(thinking_budget) =
map_openai_reasoning_effort_to_gemini_budget(reasoning_effort)
{
generation_config.insert(
"thinkingConfig".to_string(),
json!({
"includeThoughts": true,
"thinkingBudget": thinking_budget,
}),
);
}
}
if !generation_config.is_empty() {
output.insert(
"generationConfig".to_string(),
Value::Object(generation_config),
);
}
if let Some(tools) = convert_openai_tools_to_gemini(request.get("tools")) {
output.insert("tools".to_string(), tools);
}
if let Some(tool_config) = convert_openai_tool_choice_to_gemini(request.get("tool_choice")) {
output.insert("toolConfig".to_string(), tool_config);
}
if let Some(extra_body) = request.get("extra_body").and_then(Value::as_object) {
if let Some(google) = extra_body.get("google").and_then(Value::as_object) {
if let Some(existing) = output
.get_mut("generationConfig")
.and_then(Value::as_object_mut)
{
if let Some(response_modalities) = google.get("response_modalities").cloned() {
existing.insert("responseModalities".to_string(), response_modalities);
}
if let Some(thinking_config) = google.get("thinking_config").cloned() {
existing
.entry("thinkingConfig".to_string())
.or_insert(thinking_config);
}
}
}
}
Some(Value::Object(output))
}
fn convert_openai_content_to_gemini_parts(
content: Option<&Value>,
allow_images: bool,
) -> Option<Vec<Value>> {
match content {
None | Some(Value::Null) => Some(Vec::new()),
Some(Value::String(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({ "text": text })])
}
}
Some(Value::Array(parts)) => {
let mut converted = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match part_type {
"text" | "input_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
converted.push(json!({ "text": text }));
}
}
}
"image_url" | "input_image" if allow_images => return None,
_ => {}
}
}
Some(converted)
}
_ => None,
}
}
fn convert_openai_tools_to_gemini(tools: Option<&Value>) -> Option<Value> {
let tool_values = tools?.as_array()?;
let mut declarations = Vec::new();
for tool in tool_values {
let tool_object = tool.as_object()?;
if tool_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value != "function")
{
continue;
}
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut declaration = Map::new();
declaration.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description").cloned() {
declaration.insert("description".to_string(), description);
}
declaration.insert(
"parameters".to_string(),
function
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({})),
);
declarations.push(Value::Object(declaration));
}
(!declarations.is_empty()).then(|| json!([{ "functionDeclarations": declarations }]))
}
fn convert_openai_tool_choice_to_gemini(tool_choice: Option<&Value>) -> Option<Value> {
let tool_choice = tool_choice?;
match tool_choice {
Value::String(value) => {
let mode = match value.trim().to_ascii_lowercase().as_str() {
"none" => "NONE",
"required" => "ANY",
"auto" => "AUTO",
_ => return None,
};
Some(json!({
"functionCallingConfig": {
"mode": mode,
}
}))
}
Value::Object(object) => {
let function_name = object
.get("function")
.and_then(Value::as_object)
.and_then(|function| function.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": [function_name],
}
}))
}
_ => None,
}
}
fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for content in contents {
let role = content
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let parts = content
.get("parts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if parts.is_empty() {
continue;
}
if let Some(last) = compact.last_mut() {
let last_role = last
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if last_role == role {
if let Some(last_parts) = last.get_mut("parts").and_then(Value::as_array_mut) {
last_parts.extend(parts);
}
continue;
}
}
compact.push(content);
}
compact
}

View File

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

View File

@@ -0,0 +1,413 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use super::super::to_openai_chat::extract_openai_text_content;
use crate::gateway::ai_pipeline::planner::standard::copy_request_number_field;
pub(crate) fn convert_openai_chat_request_to_openai_cli_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
compact: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut instructions = Vec::new();
let mut input_items = Vec::new();
let mut next_generated_tool_call_index = 0usize;
let mut tool_call_id_aliases = BTreeMap::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
instructions.push(text);
}
}
"user" | "assistant" => {
let content_items = convert_openai_content_to_openai_cli_items(
message_object.get("content"),
role.as_str(),
)?;
if !content_items.is_empty() {
input_items.push(json!({
"type": "message",
"role": role,
"content": content_items,
}));
}
if role == "assistant" {
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let raw_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let call_id = if raw_call_id.is_empty() {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
} else {
raw_call_id.to_string()
};
if !raw_call_id.is_empty() && raw_call_id != call_id {
tool_call_id_aliases
.insert(raw_call_id.to_string(), call_id.clone());
}
let arguments = function
.get("arguments")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "{}".to_string());
input_items.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
}
}
}
}
"tool" => {
let raw_tool_call_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let tool_call_id = if raw_tool_call_id.is_empty() {
let generated = format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
} else {
tool_call_id_aliases
.get(raw_tool_call_id)
.cloned()
.unwrap_or_else(|| raw_tool_call_id.to_string())
};
let output = match message_object.get("content") {
Some(Value::String(text)) => text.clone(),
Some(other) => serde_json::to_string(other).ok()?,
None => String::new(),
};
input_items.push(json!({
"type": "function_call_output",
"call_id": tool_call_id,
"output": output,
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
if !instructions.is_empty() {
output.insert(
"instructions".to_string(),
Value::String(
instructions
.into_iter()
.filter(|value: &String| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n"),
),
);
}
output.insert("input".to_string(), Value::Array(input_items));
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
}
if let Some(max_tokens) = request.get("max_tokens").and_then(Value::as_u64) {
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
}
copy_request_number_field(request, &mut output, "temperature");
copy_request_number_field(request, &mut output, "top_p");
copy_request_integer_field(request, &mut output, "top_logprobs");
copy_request_bool_field(request, &mut output, "parallel_tool_calls");
for passthrough_key in [
"prompt_cache_key",
"service_tier",
"metadata",
"store",
"previous_response_id",
"truncation",
"reasoning",
"stop",
] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if let Some(text) = build_openai_cli_text_config_from_openai_chat_request(request) {
output.insert("text".to_string(), Value::Object(text));
}
if let Some(tools) = build_openai_cli_tools_from_openai_chat_request(request) {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = build_openai_cli_tool_choice_from_openai_chat_request(request) {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn convert_openai_content_to_openai_cli_items(
content: Option<&Value>,
role: &str,
) -> Option<Vec<Value>> {
let Some(content) = content else {
return Some(Vec::new());
};
match content {
Value::String(text) => {
if text.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
})])
}
}
Value::Array(parts) => {
let mut items = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("text")
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"text" | "input_text" | "output_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.is_empty() {
items.push(json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
}));
}
}
}
"image_url" => {
let image_url = part_object
.get("image_url")
.and_then(Value::as_object)
.and_then(|value| value.get("url"))
.and_then(Value::as_str)
.or_else(|| part_object.get("image_url").and_then(Value::as_str))?;
items.push(json!({
"type": if role == "assistant" { "output_image" } else { "input_image" },
"image_url": image_url,
}));
}
"input_image" | "output_image" => {
let image_url = part_object
.get("image_url")
.and_then(Value::as_str)
.or_else(|| part_object.get("url").and_then(Value::as_str))?;
items.push(json!({
"type": if role == "assistant" { "output_image" } else { "input_image" },
"image_url": image_url,
}));
}
_ => {}
}
}
Some(items)
}
_ => None,
}
}
fn build_openai_cli_text_config_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Map<String, Value>> {
let mut text = Map::new();
if let Some(response_format) = request.get("response_format") {
text.insert("format".to_string(), response_format.clone());
}
if let Some(verbosity) = request.get("verbosity") {
text.insert("verbosity".to_string(), verbosity.clone());
}
(!text.is_empty()).then_some(text)
}
fn build_openai_cli_tools_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Vec<Value>> {
let mut tools = Vec::new();
if let Some(tool_values) = request.get("tools").and_then(Value::as_array) {
for tool in tool_values {
let tool_object = tool.as_object()?;
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
match tool_type.as_str() {
"function" => {
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut rebuilt = Map::new();
rebuilt.insert("type".to_string(), Value::String("function".to_string()));
rebuilt.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description") {
rebuilt.insert("description".to_string(), description.clone());
}
if let Some(parameters) = function.get("parameters") {
rebuilt.insert("parameters".to_string(), parameters.clone());
}
tools.push(Value::Object(rebuilt));
}
"custom" => {
let custom = tool_object.get("custom").and_then(Value::as_object)?;
let mut rebuilt = Map::new();
rebuilt.insert("type".to_string(), Value::String("custom".to_string()));
if let Some(name) = custom.get("name") {
rebuilt.insert("name".to_string(), name.clone());
}
if let Some(description) = custom.get("description") {
rebuilt.insert("description".to_string(), description.clone());
}
if let Some(format) = custom.get("format") {
rebuilt.insert("format".to_string(), format.clone());
}
tools.push(Value::Object(rebuilt));
}
_ => tools.push(tool.clone()),
}
}
}
if let Some(web_search_options) = request.get("web_search_options").and_then(Value::as_object) {
let mut tool = Map::new();
tool.insert("type".to_string(), Value::String("web_search".to_string()));
if let Some(user_location) = web_search_options
.get("user_location")
.and_then(Value::as_object)
{
if user_location.get("type").and_then(Value::as_str) == Some("approximate") {
if let Some(approximate) =
user_location.get("approximate").and_then(Value::as_object)
{
let mut flattened = Map::new();
flattened.insert("type".to_string(), Value::String("approximate".to_string()));
if let Some(country) = approximate.get("country") {
flattened.insert("country".to_string(), country.clone());
}
if let Some(city) = approximate.get("city") {
flattened.insert("city".to_string(), city.clone());
}
tool.insert("user_location".to_string(), Value::Object(flattened));
}
}
}
if let Some(search_context_size) = web_search_options.get("search_context_size") {
tool.insert(
"search_context_size".to_string(),
search_context_size.clone(),
);
}
tools.push(Value::Object(tool));
}
(!tools.is_empty()).then_some(tools)
}
fn build_openai_cli_tool_choice_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Value> {
let tool_choice = request.get("tool_choice")?;
match tool_choice {
Value::String(value) => Some(Value::String(value.clone())),
Value::Object(object) => {
let choice_type = object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match choice_type.as_str() {
"function" => {
let function = object.get("function").and_then(Value::as_object)?;
let name = function.get("name")?.as_str()?;
Some(json!({
"type": "function",
"name": name,
}))
}
"custom" => {
let custom = object.get("custom").and_then(Value::as_object)?;
let name = custom.get("name")?.as_str()?;
Some(json!({
"type": "custom",
"name": name,
}))
}
"allowed_tools" => {
let allowed_tools = object.get("allowed_tools").and_then(Value::as_object)?;
Some(json!({
"type": "allowed_tools",
"mode": allowed_tools.get("mode").cloned().unwrap_or_else(|| Value::String("auto".to_string())),
"tools": allowed_tools.get("tools").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
}))
}
_ => Some(tool_choice.clone()),
}
}
_ => Some(tool_choice.clone()),
}
}
fn copy_request_integer_field(
request: &Map<String, Value>,
output: &mut Map<String, Value>,
field: &str,
) {
if let Some(value) = request.get(field).and_then(Value::as_i64) {
output.insert(field.to_string(), Value::from(value));
}
}
fn copy_request_bool_field(
request: &Map<String, Value>,
output: &mut Map<String, Value>,
field: &str,
) {
if let Some(value) = request.get(field).and_then(Value::as_bool) {
output.insert(field.to_string(), Value::Bool(value));
}
}

View File

@@ -0,0 +1,21 @@
use serde_json::{json, Value};
pub(super) fn parse_openai_tool_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments {
Some(Value::Object(object)) => Some(Value::Object(object.clone())),
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
Some(json!({}))
} else {
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Object(object)) => Some(Value::Object(object)),
Ok(other) => Some(json!({ "input": other })),
Err(_) => Some(json!({ "input": trimmed })),
}
}
}
Some(other) => Some(json!({ "input": other })),
None => Some(json!({})),
}
}

View File

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

View File

@@ -0,0 +1,313 @@
use serde_json::{json, Map, Value};
use uuid::Uuid;
use super::shared::canonical_json_string;
pub(crate) fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request.get("model") {
output.insert("model".to_string(), model.clone());
}
let mut messages = Vec::new();
if let Some(system_text) = extract_claude_system_text(request.get("system")) {
if !system_text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": system_text,
}));
}
}
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"user" => {
let mut text_segments = Vec::new();
if let Some(content) = message_object.get("content") {
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) => {
if !text.trim().is_empty() {
text_segments.push(text);
}
}
ClaudeNormalizedBlock::ToolResult {
tool_use_id,
content,
} => {
messages.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
}));
}
ClaudeNormalizedBlock::ToolUse { .. } => {}
}
}
}
let text = text_segments.join("\n\n");
if !text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": text,
}));
}
}
"assistant" => {
let mut text_segments = Vec::new();
let mut tool_calls = Vec::new();
if let Some(content) = message_object.get("content") {
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) => {
if !text.trim().is_empty() {
text_segments.push(text);
}
}
ClaudeNormalizedBlock::ToolUse { id, name, input } => {
tool_calls.push(json!({
"id": id.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple())),
"type": "function",
"function": {
"name": name,
"arguments": canonical_json_string(input.unwrap_or(Value::Object(Map::new()))),
}
}));
}
ClaudeNormalizedBlock::ToolResult { .. } => {}
}
}
}
let mut assistant = Map::new();
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
assistant.insert(
"content".to_string(),
if text_segments.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text_segments.join("\n\n"))
},
);
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
messages.push(Value::Object(assistant));
}
_ => {}
}
}
}
output.insert("messages".to_string(), Value::Array(messages));
if let Some(max_tokens) = request.get("max_tokens").cloned() {
output.insert("max_completion_tokens".to_string(), max_tokens);
}
for passthrough_key in ["temperature", "top_p", "metadata", "stop", "stream"] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if let Some(tools) = normalize_claude_tools_to_openai(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = normalize_claude_tool_choice_to_openai(request.get("tool_choice"))? {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
#[derive(Debug)]
enum ClaudeNormalizedBlock {
Text(String),
ToolUse {
id: Option<String>,
name: String,
input: Option<Value>,
},
ToolResult {
tool_use_id: String,
content: Value,
},
}
fn normalize_claude_content_blocks(content: &Value) -> Option<Vec<ClaudeNormalizedBlock>> {
match content {
Value::String(text) => Some(vec![ClaudeNormalizedBlock::Text(text.clone())]),
Value::Array(blocks) => {
let mut normalized = Vec::new();
for block in blocks {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" | "thinking" => {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
normalized.push(ClaudeNormalizedBlock::Text(text.to_string()));
}
"tool_use" => {
let name = block
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
normalized.push(ClaudeNormalizedBlock::ToolUse {
id: block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
name,
input: block.get("input").cloned(),
});
}
"tool_result" => {
let tool_use_id = block
.get("tool_use_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let content = block.get("content").cloned().unwrap_or(Value::Null);
normalized.push(ClaudeNormalizedBlock::ToolResult {
tool_use_id,
content,
});
}
_ => {}
}
}
Some(normalized)
}
_ => None,
}
}
fn extract_claude_system_text(system: Option<&Value>) -> Option<String> {
let system = system?;
let text = match system {
Value::String(text) => text.clone(),
Value::Array(blocks) => {
let mut segments = Vec::new();
for block in blocks {
let block = block.as_object()?;
if block.get("type").and_then(Value::as_str).unwrap_or("text") == "text" {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if !text.trim().is_empty() {
segments.push(text.to_string());
}
}
}
segments.join("\n\n")
}
_ => return None,
};
Some(strip_claude_billing_header(&text))
}
fn strip_claude_billing_header(text: &str) -> String {
let trimmed = text.trim();
let prefix = "x-anthropic-billing-header:";
if !trimmed.to_ascii_lowercase().starts_with(prefix) {
return trimmed.to_string();
}
let remainder = trimmed
.split_once('\n')
.map(|(_, rest)| rest.trim_start())
.unwrap_or_default();
remainder.trim_start_matches('\n').trim().to_string()
}
fn normalize_claude_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(tools) = tools else {
return Some(None);
};
let tools = tools.as_array()?;
let mut normalized = Vec::new();
for tool in tools {
let tool = tool.as_object()?;
let name = tool
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = tool.get("description").and_then(Value::as_str) {
if !description.trim().is_empty() {
function.insert(
"description".to_string(),
Value::String(description.trim().to_string()),
);
}
}
function.insert(
"parameters".to_string(),
tool.get("input_schema")
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
);
normalized.push(json!({
"type": "function",
"function": Value::Object(function),
}));
}
Some(Some(normalized))
}
fn normalize_claude_tool_choice_to_openai(tool_choice: Option<&Value>) -> Option<Option<Value>> {
let Some(tool_choice) = tool_choice else {
return Some(None);
};
match tool_choice {
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Some(Value::String("auto".to_string()))),
"any" => Some(Some(Value::String("required".to_string()))),
"none" => Some(Some(Value::String("none".to_string()))),
_ => Some(None),
},
Value::Object(value) => {
if let Some(name) = value.get("name").and_then(Value::as_str) {
return Some(Some(json!({
"type": "function",
"function": { "name": name }
})));
}
let kind = value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match kind.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Some(Value::String("auto".to_string()))),
"any" => Some(Some(Value::String("required".to_string()))),
"none" => Some(Some(Value::String("none".to_string()))),
"tool" => value
.get("name")
.and_then(Value::as_str)
.map(|name| {
Some(json!({
"type": "function",
"function": { "name": name }
}))
})
.or(Some(None)),
_ => Some(None),
}
}
_ => Some(None),
}
}

View File

@@ -0,0 +1,292 @@
use serde_json::{json, Map, Value};
use super::shared::canonical_json_string;
pub(crate) fn normalize_gemini_request_to_openai_chat_request(
body_json: &Value,
request_path: &str,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
output.insert("model".to_string(), Value::String(model.to_string()));
} else if let Some(model) = extract_gemini_model_from_path(request_path) {
output.insert("model".to_string(), Value::String(model));
}
let mut messages = Vec::new();
if let Some(system_text) = extract_gemini_system_text(
request
.get("systemInstruction")
.or_else(|| request.get("system_instruction")),
) {
if !system_text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": system_text,
}));
}
}
if let Some(contents) = request.get("contents").and_then(Value::as_array) {
for content in contents {
let content_object = content.as_object()?;
let role = content_object
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
let parts = content_object.get("parts").and_then(Value::as_array)?;
match role.as_str() {
"model" => {
let mut text_segments = Vec::new();
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
text_segments.push(text.to_string());
}
} else if let Some(function_call) =
part.get("functionCall").and_then(Value::as_object)
{
let name = function_call
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}_{}", name, index));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": canonical_json_string(function_call.get("args").cloned().unwrap_or(Value::Object(Map::new()))),
}
}));
}
}
let mut assistant = Map::new();
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
assistant.insert(
"content".to_string(),
if text_segments.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text_segments.join("\n\n"))
},
);
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
messages.push(Value::Object(assistant));
}
_ => {
let mut text_segments = Vec::new();
for part in parts {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
text_segments.push(text.to_string());
}
} else if let Some(function_response) =
part.get("functionResponse").and_then(Value::as_object)
{
let name = function_response
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool");
let response_value = function_response
.get("response")
.cloned()
.unwrap_or(Value::Object(Map::new()));
let tool_call_id = function_response
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", name));
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": response_value,
}));
}
}
let text = text_segments.join("\n\n");
if !text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": text,
}));
}
}
}
}
}
output.insert("messages".to_string(), Value::Array(messages));
let generation_config = request
.get("generationConfig")
.or_else(|| request.get("generation_config"))
.and_then(Value::as_object);
if let Some(generation_config) = generation_config {
if let Some(value) = generation_config.get("maxOutputTokens").cloned() {
output.insert("max_completion_tokens".to_string(), value);
}
if let Some(value) = generation_config.get("temperature").cloned() {
output.insert("temperature".to_string(), value);
}
if let Some(value) = generation_config.get("topP").cloned() {
output.insert("top_p".to_string(), value);
}
if let Some(value) = generation_config.get("candidateCount").cloned() {
output.insert("n".to_string(), value);
}
if let Some(value) = generation_config.get("stopSequences").cloned() {
output.insert("stop".to_string(), value);
}
}
if let Some(value) = request.get("stream").cloned() {
output.insert("stream".to_string(), value);
}
if let Some(tools) = normalize_gemini_tools_to_openai(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = normalize_gemini_tool_choice_to_openai(request.get("toolConfig"))? {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn extract_gemini_system_text(system_instruction: Option<&Value>) -> Option<String> {
let system_instruction = system_instruction?;
match system_instruction {
Value::String(text) => Some(text.trim().to_string()),
Value::Object(object) => {
let parts = object.get("parts")?.as_array()?;
let mut segments = Vec::new();
for part in parts {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
segments.push(text.to_string());
}
}
}
Some(segments.join("\n\n"))
}
_ => None,
}
}
fn normalize_gemini_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(tools) = tools else {
return Some(None);
};
let tools = tools.as_array()?;
let mut normalized = Vec::new();
for tool in tools {
let tool = tool.as_object()?;
let declarations = tool
.get("functionDeclarations")
.or_else(|| tool.get("function_declarations"))
.and_then(Value::as_array);
let Some(declarations) = declarations else {
continue;
};
for declaration in declarations {
let declaration = declaration.as_object()?;
let name = declaration
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = declaration.get("description").and_then(Value::as_str) {
if !description.trim().is_empty() {
function.insert(
"description".to_string(),
Value::String(description.trim().to_string()),
);
}
}
function.insert(
"parameters".to_string(),
declaration
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
);
normalized.push(json!({
"type": "function",
"function": Value::Object(function),
}));
}
}
Some(Some(normalized))
}
fn normalize_gemini_tool_choice_to_openai(tool_config: Option<&Value>) -> Option<Option<Value>> {
let Some(tool_config) = tool_config else {
return Some(None);
};
let tool_config = tool_config.as_object()?;
let function_config = tool_config
.get("functionCallingConfig")
.or_else(|| tool_config.get("function_calling_config"))
.and_then(Value::as_object)?;
let mode = function_config
.get("mode")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_uppercase();
match mode.as_str() {
"NONE" => Some(Some(Value::String("none".to_string()))),
"AUTO" => Some(Some(Value::String("auto".to_string()))),
"ANY" | "REQUIRED" => Some(Some(Value::String("required".to_string()))),
_ => {
if let Some(name) = function_config
.get("allowedFunctionNames")
.or_else(|| function_config.get("allowed_function_names"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
{
Some(Some(json!({
"type": "function",
"function": { "name": name }
})))
} else {
Some(None)
}
}
}
}
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
let tail = &path[start..];
let end = tail.find(':').unwrap_or(tail.len());
let model = tail[..end].trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}

View File

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

View File

@@ -0,0 +1,310 @@
use serde_json::{json, Map, Value};
use super::shared::{extract_openai_text_content, parse_openai_tool_result_content};
pub(crate) fn normalize_openai_cli_request_to_openai_chat_request(
body_json: &Value,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request.get("model") {
output.insert("model".to_string(), model.clone());
}
let mut messages = Vec::new();
if let Some(instructions) = request.get("instructions") {
let text = extract_openai_text_content(Some(instructions))?;
if !text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": text,
}));
}
}
messages.extend(normalize_openai_cli_input_to_openai_chat_messages(
request.get("input"),
)?);
output.insert("messages".to_string(), Value::Array(messages));
if let Some(max_output_tokens) = request.get("max_output_tokens").cloned() {
output.insert("max_completion_tokens".to_string(), max_output_tokens);
}
for passthrough_key in [
"temperature",
"top_p",
"metadata",
"store",
"previous_response_id",
"service_tier",
"reasoning",
"stop",
"stream",
] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if let Some(tools) = normalize_openai_cli_tools_to_openai_chat(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) =
normalize_openai_cli_tool_choice_to_openai_chat(request.get("tool_choice"))?
{
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) -> Option<Vec<Value>> {
let Some(input) = input else {
return Some(Vec::new());
};
match input {
Value::Null => Some(Vec::new()),
Value::String(text) => {
if text.trim().is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({
"role": "user",
"content": text,
})])
}
}
Value::Array(items) => {
let mut messages = Vec::new();
let mut next_generated_tool_call_index = 0usize;
for item in items {
if let Some(item_text) = item.as_str() {
if !item_text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": item_text,
}));
}
continue;
}
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("message")
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
let role = item_object
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
if role == "system" || role == "developer" {
let text = extract_openai_text_content(item_object.get("content"))?;
if !text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": text,
}));
}
continue;
}
let normalized_content =
normalize_openai_cli_message_content(item_object.get("content"))?;
messages.push(json!({
"role": role,
"content": normalized_content,
}));
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = item_object
.get("call_id")
.or_else(|| item_object.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
});
let arguments = item_object
.get("arguments")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "{}".to_string());
messages.push(json!({
"role": "assistant",
"content": Value::Array(Vec::new()),
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}]
}));
}
"function_call_output" => {
let tool_call_id = item_object
.get("call_id")
.or_else(|| item_object.get("tool_call_id"))
.or_else(|| item_object.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
});
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": parse_openai_tool_result_content(item_object.get("output")),
}));
}
_ => {}
}
}
Some(messages)
}
_ => None,
}
}
fn normalize_openai_cli_message_content(content: Option<&Value>) -> Option<Value> {
let Some(content) = content else {
return Some(Value::Array(Vec::new()));
};
match content {
Value::String(text) => Some(Value::String(text.clone())),
Value::Array(parts) => {
let mut normalized = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"input_text" | "output_text" | "text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
normalized.push(json!({
"type": "text",
"text": text,
}));
}
}
"input_image" | "output_image" | "image_url" => {
let image_url = part_object
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part_object
.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})?;
normalized.push(json!({
"type": "input_image",
"image_url": image_url,
}));
}
_ => {}
}
}
Some(Value::Array(normalized))
}
_ => Some(content.clone()),
}
}
fn normalize_openai_cli_tools_to_openai_chat(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(Value::Array(tool_values)) = tools else {
return Some(None);
};
let mut normalized = Vec::new();
for tool in tool_values {
let tool_object = tool.as_object()?;
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
if tool_object.get("function").is_some() || tool_type != "function" {
normalized.push(tool.clone());
continue;
}
let name = tool_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = tool_object.get("description") {
function.insert("description".to_string(), description.clone());
}
if let Some(parameters) = tool_object.get("parameters") {
function.insert("parameters".to_string(), parameters.clone());
}
normalized.push(json!({
"type": "function",
"function": function,
}));
}
Some((!normalized.is_empty()).then_some(normalized))
}
fn normalize_openai_cli_tool_choice_to_openai_chat(
tool_choice: Option<&Value>,
) -> Option<Option<Value>> {
let Some(tool_choice) = tool_choice else {
return Some(None);
};
match tool_choice {
Value::Object(object)
if object.get("function").is_none()
&& object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("function")) =>
{
let name = object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(Some(json!({
"type": "function",
"function": {
"name": name,
}
})))
}
_ => Some(Some(tool_choice.clone())),
}
}

View File

@@ -0,0 +1,66 @@
use serde_json::Value;
pub(crate) fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
match content {
None | Some(Value::Null) => Some(String::new()),
Some(Value::String(text)) => Some(text.clone()),
Some(Value::Array(parts)) => {
let mut collected = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if matches!(part_type, "text" | "input_text") {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
collected.push(text.to_string());
}
}
}
}
Some(collected.join("\n"))
}
_ => None,
}
}
pub(crate) fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
match content {
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
Value::String(String::new())
} else {
serde_json::from_str::<Value>(trimmed)
.unwrap_or_else(|_| Value::String(raw.clone()))
}
}
Some(Value::Array(parts)) => {
let texts = parts
.iter()
.filter_map(|part| {
part.as_object()
.and_then(|object| object.get("text"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.collect::<Vec<_>>();
if texts.is_empty() {
Value::Array(parts.clone())
} else {
Value::String(texts.join("\n"))
}
}
Some(value) => value.clone(),
None => Value::String(String::new()),
}
}
pub(super) fn canonical_json_string(value: Value) -> String {
match value {
Value::String(text) => text,
other => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
}
}

View File

@@ -0,0 +1,95 @@
use serde_json::{json, Value};
use super::shared::{
build_generated_tool_call_id, extract_openai_assistant_text, parse_openai_function_arguments,
};
pub(crate) fn convert_openai_chat_response_to_claude_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut content = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
content.push(json!({
"type": "text",
"text": text,
}));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let input = parse_openai_function_arguments(function.get("arguments"))?;
content.push(json!({
"type": "tool_use",
"id": tool_id,
"name": tool_name,
"input": input,
}));
}
}
if content.is_empty() {
content.push(json!({
"type": "text",
"text": "",
}));
}
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "end_turn",
Some("length") => "max_tokens",
Some("tool_calls") | Some("function_call") => "tool_use",
Some("content_filter") => "content_filtered",
Some(other) => other,
};
let usage = body.get("usage").and_then(Value::as_object);
let input_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("msg-local-finalize");
Some(json!({
"id": id,
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stop_reason,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
}))
}

View File

@@ -0,0 +1,101 @@
use serde_json::{json, Value};
use super::shared::{
build_generated_tool_call_id, extract_openai_assistant_text, parse_openai_function_arguments,
};
pub(crate) fn convert_openai_chat_response_to_gemini_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut parts = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
parts.push(json!({ "text": text }));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
parts.push(json!({
"functionCall": {
"id": call_id,
"name": tool_name,
"args": parse_openai_function_arguments(function.get("arguments"))?,
}
}));
}
}
if parts.is_empty() {
parts.push(json!({ "text": "" }));
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut finish_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "STOP",
Some("length") => "MAX_TOKENS",
Some("content_filter") => "SAFETY",
Some("tool_calls") | Some("function_call") => "STOP",
Some(other) => other,
};
if parts.iter().any(|part| part.get("functionCall").is_some()) {
finish_reason = "STOP";
}
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(json!({
"responseId": response_id,
"modelVersion": model,
"candidates": [{
"content": {
"role": "model",
"parts": parts,
},
"finishReason": finish_reason,
"index": 0,
}],
"usageMetadata": {
"promptTokenCount": prompt_tokens,
"candidatesTokenCount": completion_tokens,
"totalTokenCount": total_tokens,
}
}))
}

View File

@@ -0,0 +1,9 @@
mod claude_chat;
mod gemini_chat;
mod openai_cli;
mod shared;
pub(crate) use claude_chat::convert_openai_chat_response_to_claude_chat;
pub(crate) use gemini_chat::convert_openai_chat_response_to_gemini_chat;
pub(crate) use openai_cli::convert_openai_chat_response_to_openai_cli;
pub(crate) use shared::build_openai_cli_response;

View File

@@ -0,0 +1,99 @@
use serde_json::{json, Value};
use super::shared::{
build_openai_cli_response, canonicalize_tool_arguments,
};
pub(crate) fn convert_openai_chat_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
compact: bool,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut text = String::new();
match message.get("content") {
Some(Value::String(value)) => text.push_str(value),
Some(Value::Array(parts)) => {
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
}
Some(Value::Null) | None => {}
_ => return None,
}
let mut function_calls = Vec::new();
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_call_values {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
function_calls.push(json!({
"type": "function_call",
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"name": tool_name,
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
}));
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let response_id = if compact {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
} else {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
Some(build_openai_cli_response(
&response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,86 @@
use serde_json::{json, Map, Value};
pub(crate) fn build_openai_cli_response(
response_id: &str,
model: &str,
text: &str,
function_calls: Vec<Value>,
prompt_tokens: u64,
output_tokens: u64,
total_tokens: u64,
) -> Value {
let mut output = Vec::new();
if !text.is_empty() {
output.push(json!({
"type": "message",
"id": format!("{response_id}_msg"),
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": text,
"annotations": []
}]
}));
}
output.extend(function_calls);
json!({
"id": response_id,
"object": "response",
"status": "completed",
"model": model,
"output": output,
"usage": {
"input_tokens": prompt_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
}
})
}
pub(super) fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::String(text) => serde_json::from_str(&text)
.ok()
.or(Some(Value::String(text))),
other => Some(other),
}
}
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub(super) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
match value {
Some(Value::String(text)) => text,
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
None => "{}".to_string(),
}
}

View File

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

View File

@@ -0,0 +1,96 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub(crate) fn convert_claude_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let tool_id = block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
}
_ => return None,
}
}
let mut finish_reason = match body.get("stop_reason").and_then(Value::as_str) {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + completion_tokens;
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
let message_content = if text.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}

View File

@@ -0,0 +1,70 @@
use serde_json::{json, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
use super::super::from_openai_chat::build_openai_cli_response;
pub(crate) fn convert_claude_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
let mut function_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let call_id = block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
}
_ => return None,
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + output_tokens;
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(build_openai_cli_response(
response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,100 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub(crate) fn convert_gemini_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
let tool_name = function_call.get("name")?.as_str()?;
let tool_id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
} else {
return None;
}
}
let mut finish_reason = match first_candidate.get("finishReason").and_then(Value::as_str) {
Some("STOP") => Some("stop"),
Some("MAX_TOKENS") => Some("length"),
Some("SAFETY") => Some("content_filter"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let usage = body.get("usageMetadata").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("promptTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("candidatesTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("responseId")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
let message_content = if text.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": first_candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}

View File

@@ -0,0 +1,83 @@
use serde_json::{json, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
use super::super::from_openai_chat::build_openai_cli_response;
pub(crate) fn convert_gemini_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
let mut function_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
let tool_name = function_call.get("name")?.as_str()?;
let call_id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
} else {
return None;
}
}
let usage = body.get("usageMetadata").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("promptTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.map(|value| {
value
.get("candidatesTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
+ value
.get("thoughtsTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
})
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(build_openai_cli_response(
response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

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

View File

@@ -0,0 +1,135 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub(crate) fn convert_openai_cli_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
for (index, item) in output_items.iter().enumerate() {
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
for part in content {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "output_text" | "text") {
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
{
text.push_str(piece);
}
}
}
}
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = item_object
.get("call_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or_else(|| {
item_object
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
}
}));
}
"output_text" | "text" => {
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
_ => {}
}
}
}
let finish_reason = if tool_calls.is_empty() {
Some("stop")
} else {
Some("tool_calls")
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-openai-cli");
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
if text.is_empty() && !tool_calls.is_empty() {
message.insert("content".to_string(), Value::Null);
} else {
message.insert("content".to_string(), Value::String(text));
}
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}

View File

@@ -0,0 +1,13 @@
use serde_json::Value;
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub(super) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
match value {
Some(Value::String(text)) => text,
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
None => "{}".to_string(),
}
}

View File

@@ -0,0 +1,208 @@
use std::collections::BTreeMap;
use axum::body::Body;
use axum::http::Response;
use serde_json::Value;
pub(crate) use crate::gateway::ai_pipeline::runtime::{
normalize_provider_private_response_value as unwrap_local_finalize_response_value,
provider_private_response_allows_sync_finalize as local_finalize_allows_envelope,
};
use crate::gateway::{
build_client_response_from_parts, GatewayControlDecision, GatewayError,
GatewaySyncReportRequest,
};
pub(crate) struct LocalCoreSyncFinalizeOutcome {
pub(crate) response: Response<Body>,
pub(crate) background_report: Option<GatewaySyncReportRequest>,
}
pub(crate) fn build_local_success_outcome(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
body_json: Value,
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
let headers = payload.headers.clone();
let background_report =
map_local_finalize_to_success_report(payload, body_json.clone(), headers.clone());
build_local_success_outcome_with_report(
trace_id,
decision,
payload.status_code,
body_json,
headers,
background_report,
)
}
pub(crate) fn build_local_success_outcome_with_report(
trace_id: &str,
decision: &GatewayControlDecision,
status_code: u16,
body_json: Value,
mut headers: BTreeMap<String, String>,
background_report: Option<GatewaySyncReportRequest>,
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
headers.remove("content-encoding");
headers.remove("content-length");
headers.insert("content-type".to_string(), "application/json".to_string());
let body_bytes =
serde_json::to_vec(&body_json).map_err(|err| GatewayError::Internal(err.to_string()))?;
headers.insert("content-length".to_string(), body_bytes.len().to_string());
let response = build_client_response_from_parts(
status_code,
&headers,
Body::from(body_bytes),
trace_id,
Some(decision),
)?;
Ok(LocalCoreSyncFinalizeOutcome {
response,
background_report,
})
}
pub(crate) fn build_local_success_outcome_with_conversion_report(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
client_body_json: Value,
provider_body_json: Value,
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
let Some(report_kind) =
map_local_finalize_kind_to_success_report_kind(payload.report_kind.as_str())
else {
return build_local_success_outcome_with_report(
trace_id,
decision,
payload.status_code,
client_body_json,
payload.headers.clone(),
None,
);
};
let report_payload = GatewaySyncReportRequest {
trace_id: payload.trace_id.clone(),
report_kind: report_kind.to_string(),
report_context: payload.report_context.clone(),
status_code: payload.status_code,
headers: payload.headers.clone(),
body_json: Some(provider_body_json),
client_body_json: Some(client_body_json.clone()),
body_base64: None,
telemetry: payload.telemetry.clone(),
};
build_local_success_outcome_with_report(
trace_id,
decision,
payload.status_code,
client_body_json,
payload.headers.clone(),
Some(report_payload),
)
}
fn map_local_finalize_to_success_report(
payload: &GatewaySyncReportRequest,
body_json: Value,
headers: BTreeMap<String, String>,
) -> Option<GatewaySyncReportRequest> {
let report_kind = map_local_finalize_kind_to_success_report_kind(payload.report_kind.as_str())?;
Some(GatewaySyncReportRequest {
trace_id: payload.trace_id.clone(),
report_kind: report_kind.to_string(),
report_context: payload.report_context.clone(),
status_code: payload.status_code,
headers,
body_json: Some(body_json),
client_body_json: None,
body_base64: None,
telemetry: payload.telemetry.clone(),
})
}
fn map_local_finalize_kind_to_success_report_kind(report_kind: &str) -> Option<&'static str> {
match report_kind {
"openai_chat_sync_finalize" => Some("openai_chat_sync_success"),
"claude_chat_sync_finalize" => Some("claude_chat_sync_success"),
"gemini_chat_sync_finalize" => Some("gemini_chat_sync_success"),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize" => {
Some("openai_cli_sync_success")
}
"claude_cli_sync_finalize" => Some("claude_cli_sync_success"),
"gemini_cli_sync_finalize" => Some("gemini_cli_sync_success"),
_ => None,
}
}
pub(crate) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
match value {
Some(Value::String(text)) => text,
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
None => "{}".to_string(),
}
}
pub(crate) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub(crate) fn parse_stream_json_events(body: &[u8]) -> Option<Vec<Value>> {
let text = std::str::from_utf8(body).ok()?;
let trimmed = text.trim();
if trimmed.is_empty() {
return Some(Vec::new());
}
if trimmed.starts_with('[') {
let array_value: Value = serde_json::from_str(trimmed).ok()?;
let array = array_value.as_array()?;
return Some(
array
.iter()
.filter(|value| value.is_object())
.cloned()
.collect(),
);
}
let mut events = Vec::new();
let mut current_event_type: Option<String> = None;
for raw_line in text.lines() {
let line = raw_line.trim_matches('\r').trim();
if line.is_empty() || line.starts_with(':') {
continue;
}
if let Some(event_name) = line.strip_prefix("event:") {
current_event_type = Some(event_name.trim().to_string());
continue;
}
let data_line = if let Some(rest) = line.strip_prefix("data:") {
rest.trim()
} else {
line
};
if data_line.is_empty() || data_line == "[DONE]" {
continue;
}
let mut event: Value = serde_json::from_str(data_line).ok()?;
if let Some(event_object) = event.as_object_mut() {
if !event_object.contains_key("type") {
if let Some(event_name) = current_event_type.take() {
event_object.insert("type".to_string(), Value::String(event_name));
}
}
}
events.push(event);
current_event_type = None;
}
Some(events)
}

View File

@@ -0,0 +1,38 @@
use axum::body::Body;
use axum::http::Response;
use serde_json::Value;
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
#[path = "stream_rewrite.rs"]
pub(crate) mod stream;
#[path = "sync_finalize.rs"]
pub(crate) mod sync;
pub(crate) use stream::LocalStreamRewriter;
pub(crate) use sync::LocalCoreSyncFinalizeOutcome;
pub(crate) fn maybe_build_sync_finalize_outcome(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
sync::maybe_build_local_core_sync_finalize_response(trace_id, decision, payload)
}
pub(crate) fn maybe_compile_sync_finalize_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<Response<Body>>, GatewayError> {
Ok(
maybe_build_sync_finalize_outcome(trace_id, decision, payload)?
.map(|outcome| outcome.response),
)
}
pub(crate) fn maybe_build_stream_response_rewriter(
report_context: Option<&Value>,
) -> Option<LocalStreamRewriter> {
stream::maybe_build_local_stream_rewriter(report_context)
}

View File

@@ -0,0 +1,326 @@
use serde_json::Value;
use crate::gateway::ai_pipeline::finalize::standard::{
BufferedCliConversionStreamState, BufferedStandardConversionStreamState,
ClaudeToOpenAIChatStreamState, GeminiToOpenAIChatStreamState, OpenAICliToOpenAIChatStreamState,
};
use crate::gateway::ai_pipeline::finalize::sse::{encode_done_sse, encode_json_sse};
use crate::gateway::ai_pipeline::private_response::transform_provider_private_stream_line as transform_envelope_line;
use crate::gateway::ai_pipeline::runtime::KiroToClaudeCliStreamState;
use crate::gateway::GatewayError;
use super::sync::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli,
};
enum RewriteMode {
EnvelopeUnwrap,
ClaudeToOpenAIChat(ClaudeToOpenAIChatStreamState),
GeminiToOpenAIChat(GeminiToOpenAIChatStreamState),
OpenAICliToOpenAIChat(OpenAICliToOpenAIChatStreamState),
ClaudeToOpenAICli(BufferedCliConversionStreamState),
GeminiToOpenAICli(BufferedCliConversionStreamState),
AntigravityGeminiToOpenAIChat(GeminiToOpenAIChatStreamState),
AntigravityGeminiToOpenAICli(BufferedCliConversionStreamState),
KiroToClaudeCli(KiroToClaudeCliStreamState),
StandardChat(BufferedStandardConversionStreamState),
StandardCli(BufferedStandardConversionStreamState),
}
pub(crate) struct LocalStreamRewriter {
report_context: Value,
buffered: Vec<u8>,
mode: RewriteMode,
}
pub(crate) fn maybe_build_local_stream_rewriter(
report_context: Option<&Value>,
) -> Option<LocalStreamRewriter> {
let report_context = report_context?;
let needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
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 mode = if needs_conversion {
match (
envelope_name.as_str(),
provider_api_format.as_str(),
client_api_format.as_str(),
) {
("", "claude:chat", "openai:chat") => {
RewriteMode::ClaudeToOpenAIChat(ClaudeToOpenAIChatStreamState::default())
}
("", "gemini:chat", "openai:chat") => {
RewriteMode::GeminiToOpenAIChat(GeminiToOpenAIChatStreamState::default())
}
("", "openai:cli", "openai:chat") | ("", "openai:compact", "openai:chat") => {
RewriteMode::OpenAICliToOpenAIChat(OpenAICliToOpenAIChatStreamState::default())
}
("", "claude:cli", "openai:cli") => {
RewriteMode::ClaudeToOpenAICli(BufferedCliConversionStreamState::default())
}
("", "claude:cli", "openai:compact") => {
RewriteMode::ClaudeToOpenAICli(BufferedCliConversionStreamState::default())
}
("", "gemini:cli", "openai:cli") => {
RewriteMode::GeminiToOpenAICli(BufferedCliConversionStreamState::default())
}
("", "gemini:cli", "openai:compact") => {
RewriteMode::GeminiToOpenAICli(BufferedCliConversionStreamState::default())
}
("antigravity:v1internal", "gemini:chat", "openai:chat") => {
RewriteMode::AntigravityGeminiToOpenAIChat(GeminiToOpenAIChatStreamState::default())
}
("antigravity:v1internal", "gemini:cli", "openai:cli") => {
RewriteMode::AntigravityGeminiToOpenAICli(
BufferedCliConversionStreamState::default(),
)
}
("antigravity:v1internal", "gemini:cli", "openai:compact") => {
RewriteMode::AntigravityGeminiToOpenAICli(
BufferedCliConversionStreamState::default(),
)
}
_ if is_standard_chat_client_api_format(client_api_format.as_str())
&& is_standard_provider_api_format(provider_api_format.as_str()) =>
{
RewriteMode::StandardChat(BufferedStandardConversionStreamState::default())
}
_ if is_standard_cli_client_api_format(client_api_format.as_str())
&& is_standard_provider_api_format(provider_api_format.as_str()) =>
{
RewriteMode::StandardCli(BufferedStandardConversionStreamState::default())
}
_ => return None,
}
} else {
match envelope_name.as_str() {
"antigravity:v1internal" => {
if provider_api_format == client_api_format
&& matches!(provider_api_format.as_str(), "gemini:chat" | "gemini:cli")
{
RewriteMode::EnvelopeUnwrap
} else {
return None;
}
}
"gemini_cli:v1internal" => {
if provider_api_format == "gemini:cli" && client_api_format == "gemini:cli" {
RewriteMode::EnvelopeUnwrap
} else {
return None;
}
}
"kiro:generateassistantresponse" => {
if provider_api_format == "claude:cli" && client_api_format == "claude:cli" {
RewriteMode::KiroToClaudeCli(KiroToClaudeCliStreamState::new(report_context))
} else {
return None;
}
}
_ => return None,
}
};
Some(LocalStreamRewriter {
report_context: report_context.clone(),
buffered: Vec::new(),
mode,
})
}
impl LocalStreamRewriter {
pub(crate) fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, GatewayError> {
if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode {
return state.push_chunk(&self.report_context, chunk);
}
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
output.extend(self.transform_line(line)?);
}
Ok(output)
}
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode {
return state.finish(&self.report_context);
}
if self.buffered.is_empty() {
match &mut self.mode {
RewriteMode::ClaudeToOpenAIChat(state) => return Ok(state.finish()),
RewriteMode::GeminiToOpenAIChat(state) => {
return state.finish(&self.report_context);
}
RewriteMode::OpenAICliToOpenAIChat(state) => {
return state.finish(&self.report_context);
}
RewriteMode::ClaudeToOpenAICli(state) => {
return state.finish(
&self.report_context,
aggregate_claude_stream_sync_response,
convert_claude_cli_response_to_openai_cli,
);
}
RewriteMode::GeminiToOpenAICli(state) => {
return state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
);
}
RewriteMode::AntigravityGeminiToOpenAIChat(state) => {
return state.finish(&self.report_context);
}
RewriteMode::AntigravityGeminiToOpenAICli(state) => {
return state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
);
}
RewriteMode::KiroToClaudeCli(_) => {}
RewriteMode::StandardChat(state) => {
return state.finish_as_chat(&self.report_context)
}
RewriteMode::StandardCli(state) => {
return state.finish_as_cli(&self.report_context)
}
RewriteMode::EnvelopeUnwrap => {}
}
return Ok(Vec::new());
}
let line = std::mem::take(&mut self.buffered);
let mut output = self.transform_line(line)?;
match &mut self.mode {
RewriteMode::ClaudeToOpenAIChat(state) => {
output.extend(state.finish());
}
RewriteMode::GeminiToOpenAIChat(state) => {
output.extend(state.finish(&self.report_context)?);
}
RewriteMode::OpenAICliToOpenAIChat(state) => {
output.extend(state.finish(&self.report_context)?);
}
RewriteMode::ClaudeToOpenAICli(state) => {
output.extend(state.finish(
&self.report_context,
aggregate_claude_stream_sync_response,
convert_claude_cli_response_to_openai_cli,
)?);
}
RewriteMode::GeminiToOpenAICli(state) => {
output.extend(state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
)?);
}
RewriteMode::AntigravityGeminiToOpenAIChat(state) => {
output.extend(state.finish(&self.report_context)?);
}
RewriteMode::AntigravityGeminiToOpenAICli(state) => {
output.extend(state.finish(
&self.report_context,
aggregate_gemini_stream_sync_response,
convert_gemini_cli_response_to_openai_cli,
)?);
}
RewriteMode::KiroToClaudeCli(_) => {}
RewriteMode::StandardChat(state) => {
output.extend(state.finish_as_chat(&self.report_context)?);
}
RewriteMode::StandardCli(state) => {
output.extend(state.finish_as_cli(&self.report_context)?);
}
RewriteMode::EnvelopeUnwrap => {}
}
Ok(output)
}
fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, GatewayError> {
match &mut self.mode {
RewriteMode::EnvelopeUnwrap => transform_envelope_line(&self.report_context, line),
RewriteMode::ClaudeToOpenAIChat(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::GeminiToOpenAIChat(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::OpenAICliToOpenAIChat(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::ClaudeToOpenAICli(state) | RewriteMode::GeminiToOpenAICli(state) => {
state.transform_line(line)
}
RewriteMode::AntigravityGeminiToOpenAIChat(state) => {
let unwrapped = transform_envelope_line(&self.report_context, line)?;
if unwrapped.is_empty() {
Ok(Vec::new())
} else {
state.transform_line(&self.report_context, unwrapped)
}
}
RewriteMode::AntigravityGeminiToOpenAICli(state) => {
let unwrapped = transform_envelope_line(&self.report_context, line)?;
if unwrapped.is_empty() {
Ok(Vec::new())
} else {
state.transform_line(unwrapped)
}
}
RewriteMode::StandardChat(state) | RewriteMode::StandardCli(state) => {
state.transform_line(&self.report_context, line)
}
RewriteMode::KiroToClaudeCli(_) => Ok(Vec::new()),
}
}
}
fn is_standard_provider_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat"
| "openai:cli"
| "openai:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
fn is_standard_chat_client_api_format(api_format: &str) -> bool {
matches!(api_format, "openai:chat" | "claude:chat" | "gemini:chat")
}
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:cli" | "openai:compact" | "claude:cli" | "gemini:cli"
)
}
#[cfg(test)]
#[path = "../tests_stream.rs"]
mod tests;

View File

@@ -0,0 +1,377 @@
use std::collections::BTreeMap;
use axum::body::Body;
use axum::http::Response;
use base64::Engine as _;
use serde_json::{json, Map, Value};
use crate::gateway::ai_pipeline::conversion::{
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
};
use crate::gateway::{
build_client_response_from_parts, GatewayControlDecision, GatewayError,
GatewaySyncReportRequest,
};
pub(crate) use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome,
build_local_success_outcome_with_conversion_report, canonicalize_tool_arguments,
local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome,
};
pub(crate) use crate::gateway::ai_pipeline::finalize::standard::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_chat_stream_sync_response, aggregate_openai_cli_stream_sync_response,
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
convert_openai_chat_response_to_openai_cli, convert_openai_cli_response_to_openai_chat,
convert_standard_chat_response, convert_standard_cli_response,
maybe_build_local_claude_cli_stream_sync_response,
maybe_build_local_claude_stream_sync_response, maybe_build_local_claude_sync_response,
maybe_build_local_gemini_cli_stream_sync_response,
maybe_build_local_gemini_stream_sync_response, maybe_build_local_gemini_sync_response,
maybe_build_local_openai_chat_cross_format_stream_sync_response,
maybe_build_local_openai_chat_cross_format_sync_response,
maybe_build_local_openai_chat_stream_sync_response,
maybe_build_local_openai_chat_sync_response,
maybe_build_local_openai_cli_cross_format_stream_sync_response,
maybe_build_local_openai_cli_cross_format_sync_response,
maybe_build_local_openai_cli_stream_sync_response,
};
pub(crate) fn maybe_build_local_core_sync_finalize_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
let Some(normalized_payload) =
crate::gateway::ai_pipeline::private_response::maybe_normalize_provider_private_sync_report_payload(payload)?
else {
return Ok(None);
};
let payload = &normalized_payload;
if let Some(response) =
maybe_build_local_openai_chat_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_chat_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_openai_chat_cross_format_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_cli_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_cli_cross_format_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_claude_cli_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_gemini_cli_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_claude_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_claude_sync_response(trace_id, decision, payload)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_gemini_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_gemini_sync_response(trace_id, decision, payload)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_chat_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_openai_cli_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_standard_chat_cross_format_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_standard_chat_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_standard_cli_cross_format_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if let Some(response) =
maybe_build_local_standard_cli_cross_format_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
Ok(None)
}
fn maybe_build_local_standard_chat_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_chat_sync_finalize" | "claude_chat_sync_finalize" | "gemini_chat_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if !local_finalize_allows_envelope(report_context)
|| sync_chat_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(aggregated) =
aggregate_standard_chat_stream_sync_response(&body_bytes, &provider_api_format)
else {
return Ok(None);
};
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
return Ok(None);
};
let Some(converted) = convert_standard_chat_response(
&aggregated,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, aggregated,
)?))
}
fn maybe_build_local_standard_chat_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_chat_sync_finalize" | "claude_chat_sync_finalize" | "gemini_chat_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if !local_finalize_allows_envelope(report_context)
|| sync_chat_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let Some(converted) = convert_standard_chat_response(
&body_json,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, body_json,
)?))
}
fn maybe_build_local_standard_cli_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize"
| "openai_compact_sync_finalize"
| "claude_cli_sync_finalize"
| "gemini_cli_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if !local_finalize_allows_envelope(report_context)
|| sync_cli_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(aggregated) =
aggregate_standard_cli_stream_sync_response(&body_bytes, &provider_api_format)
else {
return Ok(None);
};
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
return Ok(None);
};
let Some(converted) = convert_standard_cli_response(
&aggregated,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, aggregated,
)?))
}
fn maybe_build_local_standard_cli_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize"
| "openai_compact_sync_finalize"
| "claude_cli_sync_finalize"
| "gemini_cli_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if !local_finalize_allows_envelope(report_context)
|| sync_cli_response_conversion_kind(&provider_api_format, &client_api_format).is_none()
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let Some(converted) = convert_standard_cli_response(
&body_json,
&provider_api_format,
&client_api_format,
report_context,
) else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, body_json,
)?))
}
#[cfg(test)]
#[path = "../tests_sync.rs"]
mod tests;

View File

@@ -0,0 +1,13 @@
pub(crate) mod common;
pub(crate) mod sse;
pub(crate) mod standard;
pub(crate) use common::build_local_success_outcome;
pub(crate) use internal::{
maybe_build_stream_response_rewriter, maybe_build_sync_finalize_outcome,
maybe_compile_sync_finalize_response, LocalCoreSyncFinalizeOutcome,
};
pub(crate) use crate::gateway::build_client_response;
pub(crate) use crate::gateway::build_client_response_from_parts;
pub(crate) use crate::gateway::execution_runtime::maybe_build_local_sync_finalize_response;
pub(crate) mod internal;

View File

@@ -0,0 +1,38 @@
use serde_json::Value;
use crate::gateway::GatewayError;
pub(crate) fn map_claude_stop_reason(
stop_reason: Option<&str>,
has_tool_calls: bool,
) -> Option<&'static str> {
let mapped = match stop_reason {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some("pause_turn") => Some("stop"),
_ => None,
};
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
Some("tool_calls")
} else {
mapped
}
}
pub(crate) fn encode_done_sse() -> Vec<u8> {
b"data: [DONE]\n\n".to_vec()
}
pub(crate) fn encode_json_sse(event: Option<&str>, value: &Value) -> Result<Vec<u8>, GatewayError> {
let mut out = Vec::new();
if let Some(event) = event.filter(|value| !value.trim().is_empty()) {
out.extend_from_slice(b"event: ");
out.extend_from_slice(event.as_bytes());
out.push(b'\n');
}
out.extend_from_slice(b"data: ");
out.extend(serde_json::to_vec(value).map_err(|err| GatewayError::Internal(err.to_string()))?);
out.extend_from_slice(b"\n\n");
Ok(out)
}

View File

@@ -0,0 +1,486 @@
use base64::Engine as _;
use super::*;
use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome,
};
#[derive(Debug, Default)]
struct ClaudeContentBlockState {
object: Map<String, Value>,
text: String,
partial_json: String,
}
pub(crate) fn maybe_build_local_claude_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "claude_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "claude:chat"
|| client_api_format != "claude:chat"
|| needs_conversion
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let body_json = match aggregate_claude_stream_sync_response(&body_bytes) {
Some(body_json) => body_json,
None => return Ok(None),
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
pub(crate) fn maybe_build_local_claude_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "claude_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "claude:chat"
|| client_api_format != "claude:chat"
|| needs_conversion
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
pub(crate) fn convert_claude_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let tool_id = block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
}
_ => return None,
}
}
let mut finish_reason = match body.get("stop_reason").and_then(Value::as_str) {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + completion_tokens;
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
let message_content = if text.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}
pub(crate) fn convert_openai_chat_response_to_claude_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut content = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
content.push(json!({
"type": "text",
"text": text,
}));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let input = parse_openai_function_arguments(function.get("arguments"))?;
content.push(json!({
"type": "tool_use",
"id": tool_id,
"name": tool_name,
"input": input,
}));
}
}
if content.is_empty() {
content.push(json!({
"type": "text",
"text": "",
}));
}
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "end_turn",
Some("length") => "max_tokens",
Some("tool_calls") | Some("function_call") => "tool_use",
Some("content_filter") => "content_filtered",
Some(other) => other,
};
let usage = body.get("usage").and_then(Value::as_object);
let input_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("msg-local-finalize");
Some(json!({
"id": id,
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stop_reason,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
}))
}
fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}
fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::String(text) => serde_json::from_str(&text)
.ok()
.or(Some(Value::String(text))),
other => Some(other),
}
}
pub(crate) fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
let events = parse_stream_json_events(body)?;
if events.is_empty() {
return None;
}
let mut message_object: Option<Map<String, Value>> = None;
let mut content_blocks: BTreeMap<usize, ClaudeContentBlockState> = BTreeMap::new();
let mut usage: Option<Value> = None;
let mut saw_message_start = false;
for event in events {
let event_object = event.as_object()?;
let event_type = event_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match event_type {
"message_start" => {
let mut message = event_object.get("message")?.as_object()?.clone();
usage = message.remove("usage");
message_object = Some(message);
saw_message_start = true;
}
"content_block_start" => {
let index = event_object
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let object = event_object
.get("content_block")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
content_blocks.insert(
index,
ClaudeContentBlockState {
object,
..Default::default()
},
);
}
"content_block_delta" => {
let index = event_object
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let state = content_blocks.entry(index).or_default();
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
continue;
};
match delta
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text_delta" => {
if let Some(text) = delta.get("text").and_then(Value::as_str) {
state.text.push_str(text);
}
}
"input_json_delta" => {
if let Some(partial_json) =
delta.get("partial_json").and_then(Value::as_str)
{
state.partial_json.push_str(partial_json);
}
}
_ => {}
}
}
"message_delta" => {
if let Some(message) = message_object.as_mut() {
if let Some(delta) = event_object.get("delta").and_then(Value::as_object) {
if let Some(stop_reason) = delta.get("stop_reason") {
message.insert("stop_reason".to_string(), stop_reason.clone());
}
if let Some(stop_sequence) = delta.get("stop_sequence") {
message.insert("stop_sequence".to_string(), stop_sequence.clone());
}
}
}
if let Some(delta_usage) = event_object.get("usage") {
usage = Some(delta_usage.clone());
}
}
"message_stop" => {}
_ => {}
}
}
if !saw_message_start {
return None;
}
let mut message = message_object?;
let mut content = Vec::with_capacity(content_blocks.len());
for (_index, state) in content_blocks {
let mut block = state.object;
let block_type = block
.get("type")
.and_then(Value::as_str)
.unwrap_or("text")
.to_string();
match block_type.as_str() {
"text" => {
block.insert(
"text".to_string(),
Value::String(if state.text.is_empty() {
block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
} else {
state.text
}),
);
}
"tool_use" => {
if !state.partial_json.is_empty() {
let input = serde_json::from_str::<Value>(&state.partial_json)
.unwrap_or(Value::String(state.partial_json));
block.insert("input".to_string(), input);
}
}
_ => {
if !state.text.is_empty() {
block.insert("text".to_string(), Value::String(state.text));
}
}
}
content.push(Value::Object(block));
}
message.insert("content".to_string(), Value::Array(content));
if let Some(usage_value) = usage {
message.insert("usage".to_string(), usage_value);
}
Some(Value::Object(message))
}

View File

@@ -0,0 +1,139 @@
use base64::Engine as _;
use super::aggregate_claude_stream_sync_response;
use super::*;
use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome,
};
use crate::gateway::ai_pipeline::finalize::standard::build_openai_cli_response;
pub(crate) fn maybe_build_local_claude_cli_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "claude_cli_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "claude:cli" || client_api_format != "claude:cli" || needs_conversion
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let body_json =
match aggregate_provider_claude_cli_stream_sync_response(&body_bytes, report_context)? {
Some(body_json) => body_json,
None => return Ok(None),
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
fn aggregate_provider_claude_cli_stream_sync_response(
body_bytes: &[u8],
_report_context: &Value,
) -> Result<Option<Value>, GatewayError> {
Ok(aggregate_claude_stream_sync_response(body_bytes))
}
pub(crate) fn convert_claude_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
let mut function_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
text.push_str(block.get("text")?.as_str()?);
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let call_id = block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
}
_ => return None,
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + output_tokens;
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(build_openai_cli_response(
response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,435 @@
use base64::Engine as _;
use super::*;
use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
local_finalize_allows_envelope, parse_stream_json_events, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome,
};
pub(crate) fn maybe_build_local_gemini_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "gemini_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "gemini:chat"
|| client_api_format != "gemini:chat"
|| needs_conversion
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let body_json = match aggregate_gemini_stream_sync_response(&body_bytes) {
Some(body_json) => body_json,
None => return Ok(None),
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
pub(crate) fn maybe_build_local_gemini_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "gemini_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "gemini:chat"
|| client_api_format != "gemini:chat"
|| needs_conversion
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
pub(crate) fn convert_gemini_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
let tool_name = function_call.get("name")?.as_str()?;
let tool_id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
} else {
return None;
}
}
let mut finish_reason = match first_candidate.get("finishReason").and_then(Value::as_str) {
Some("STOP") => Some("stop"),
Some("MAX_TOKENS") => Some("length"),
Some("SAFETY") => Some("content_filter"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let usage = body.get("usageMetadata").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("promptTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("candidatesTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("responseId")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
let message_content = if text.is_empty() && !tool_calls.is_empty() {
Value::Null
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": first_candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}
pub(crate) fn convert_openai_chat_response_to_gemini_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut parts = Vec::new();
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
if !text.trim().is_empty() {
parts.push(json!({ "text": text }));
}
}
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
parts.push(json!({
"functionCall": {
"id": call_id,
"name": tool_name,
"args": parse_openai_function_arguments(function.get("arguments"))?,
}
}));
}
}
if parts.is_empty() {
parts.push(json!({ "text": "" }));
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut finish_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "STOP",
Some("length") => "MAX_TOKENS",
Some("content_filter") => "SAFETY",
Some("tool_calls") | Some("function_call") => "STOP",
Some(other) => other,
};
if parts.iter().any(|part| part.get("functionCall").is_some()) {
finish_reason = "STOP";
}
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(json!({
"responseId": response_id,
"modelVersion": model,
"candidates": [{
"content": {
"role": "model",
"parts": parts,
},
"finishReason": finish_reason,
"index": 0,
}],
"usageMetadata": {
"promptTokenCount": prompt_tokens,
"candidatesTokenCount": completion_tokens,
"totalTokenCount": total_tokens,
}
}))
}
fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}
fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::String(text) => serde_json::from_str(&text)
.ok()
.or(Some(Value::String(text))),
other => Some(other),
}
}
pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
let events = parse_stream_json_events(body)?;
if events.is_empty() {
return None;
}
let mut candidates: BTreeMap<usize, Value> = BTreeMap::new();
let mut response_id: Option<Value> = None;
let mut private_response_id: Option<Value> = None;
let mut model_version: Option<Value> = None;
let mut usage_metadata: Option<Value> = None;
let mut prompt_feedback: Option<Value> = None;
let mut saw_candidate = false;
for event in events {
let raw_event_object = event.as_object()?;
if let Some(id) = raw_event_object.get("responseId") {
response_id = Some(id.clone());
}
if let Some(id) = raw_event_object.get("_v1internal_response_id") {
private_response_id = Some(id.clone());
}
let event_object = if let Some(response) = raw_event_object
.get("response")
.and_then(Value::as_object)
.filter(|response| response.contains_key("candidates"))
{
response
} else {
raw_event_object
};
if let Some(id) = event_object.get("responseId") {
response_id = Some(id.clone());
}
if let Some(id) = event_object.get("_v1internal_response_id") {
private_response_id = Some(id.clone());
}
if let Some(version) = event_object.get("modelVersion") {
model_version = Some(version.clone());
}
if let Some(usage) = event_object.get("usageMetadata") {
usage_metadata = Some(usage.clone());
}
if let Some(prompt) = event_object.get("promptFeedback") {
prompt_feedback = Some(prompt.clone());
}
let Some(event_candidates) = event_object.get("candidates").and_then(Value::as_array)
else {
continue;
};
for candidate in event_candidates {
let Some(candidate_object) = candidate.as_object() else {
continue;
};
let index = candidate_object
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
candidates.insert(index, Value::Object(candidate_object.clone()));
saw_candidate = true;
}
}
if !saw_candidate {
return None;
}
let mut response = Map::new();
if let Some(response_id) = response_id {
response.insert("responseId".to_string(), response_id);
}
if let Some(private_response_id) = private_response_id {
response.insert("_v1internal_response_id".to_string(), private_response_id);
}
response.insert(
"candidates".to_string(),
Value::Array(candidates.into_values().collect()),
);
if let Some(version) = model_version {
response.insert("modelVersion".to_string(), version);
}
if let Some(usage) = usage_metadata {
response.insert("usageMetadata".to_string(), usage);
}
if let Some(prompt) = prompt_feedback {
response.insert("promptFeedback".to_string(), prompt);
}
Some(Value::Object(response))
}

View File

@@ -0,0 +1,144 @@
use base64::Engine as _;
use super::aggregate_gemini_stream_sync_response;
use super::*;
use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome, canonicalize_tool_arguments,
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome,
};
use crate::gateway::ai_pipeline::finalize::standard::build_openai_cli_response;
pub(crate) fn maybe_build_local_gemini_cli_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "gemini_cli_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "gemini:cli" || client_api_format != "gemini:cli" || needs_conversion
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let body_json = match aggregate_gemini_stream_sync_response(&body_bytes) {
Some(body_json) => body_json,
None => return Ok(None),
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
pub(crate) fn convert_gemini_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
let mut function_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
let tool_name = function_call.get("name")?.as_str()?;
let call_id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
function_calls.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
} else {
return None;
}
}
let usage = body.get("usageMetadata").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("promptTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.map(|value| {
value
.get("candidatesTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
+ value
.get("thoughtsTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0)
})
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let response_id = body
.get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(build_openai_cli_response(
response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}

View File

@@ -0,0 +1,141 @@
//! Standard finalize surface for standard contract sync/stream compilation.
use std::collections::BTreeMap;
use serde_json::Value;
use serde_json::{json, Map};
use crate::gateway::{GatewayControlDecision, GatewayError, GatewaySyncReportRequest};
#[path = "claude/chat.rs"]
mod claude_chat;
#[path = "claude/cli.rs"]
mod claude_cli;
#[path = "gemini/chat.rs"]
mod gemini_chat;
#[path = "gemini/cli.rs"]
mod gemini_cli;
#[path = "openai/chat.rs"]
mod openai_chat;
#[path = "openai/chat_stream.rs"]
mod openai_chat_stream;
#[path = "openai/cli.rs"]
mod openai_cli;
#[path = "openai/cli_stream.rs"]
mod openai_cli_stream;
#[path = "stream.rs"]
mod stream;
pub(crate) use crate::gateway::ai_pipeline::conversion::response::{
build_openai_cli_response, convert_claude_chat_response_to_openai_chat,
convert_claude_cli_response_to_openai_cli, convert_gemini_chat_response_to_openai_chat,
convert_gemini_cli_response_to_openai_cli, convert_openai_chat_response_to_claude_chat,
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
convert_openai_cli_response_to_openai_chat,
};
pub(crate) use claude_chat::{
aggregate_claude_stream_sync_response, maybe_build_local_claude_stream_sync_response,
maybe_build_local_claude_sync_response,
};
pub(crate) use claude_cli::maybe_build_local_claude_cli_stream_sync_response;
pub(crate) use gemini_chat::{
aggregate_gemini_stream_sync_response, maybe_build_local_gemini_stream_sync_response,
maybe_build_local_gemini_sync_response,
};
pub(crate) use gemini_cli::maybe_build_local_gemini_cli_stream_sync_response;
pub(crate) use openai_chat::{
aggregate_openai_chat_stream_sync_response, maybe_build_local_openai_chat_cross_format_stream_sync_response,
maybe_build_local_openai_chat_cross_format_sync_response,
maybe_build_local_openai_chat_stream_sync_response,
maybe_build_local_openai_chat_sync_response,
};
pub(crate) use openai_chat_stream::{
ClaudeToOpenAIChatStreamState, GeminiToOpenAIChatStreamState, OpenAICliToOpenAIChatStreamState,
};
pub(crate) use openai_cli::{
aggregate_openai_cli_stream_sync_response, maybe_build_local_openai_cli_cross_format_stream_sync_response,
maybe_build_local_openai_cli_cross_format_sync_response,
maybe_build_local_openai_cli_stream_sync_response,
};
pub(crate) use openai_cli_stream::BufferedCliConversionStreamState;
pub(crate) use stream::BufferedStandardConversionStreamState;
pub(crate) fn aggregate_standard_chat_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Option<Value> {
match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => aggregate_openai_chat_stream_sync_response(body),
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(body),
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(body),
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(body),
_ => None,
}
}
pub(crate) fn convert_standard_chat_response(
body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
let canonical = match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => body_json.clone(),
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(body_json, report_context)?
}
"claude:chat" | "claude:cli" => {
convert_claude_chat_response_to_openai_chat(body_json, report_context)?
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_chat_response_to_openai_chat(body_json, report_context)?
}
_ => return None,
};
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => Some(canonical),
"claude:chat" => convert_openai_chat_response_to_claude_chat(&canonical, report_context),
"gemini:chat" => convert_openai_chat_response_to_gemini_chat(&canonical, report_context),
_ => None,
}
}
pub(crate) fn aggregate_standard_cli_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Option<Value> {
aggregate_standard_chat_stream_sync_response(body, provider_api_format)
}
pub(crate) fn convert_standard_cli_response(
body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
let canonical = match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(body_json, report_context)?
}
_ => convert_standard_chat_response(
body_json,
provider_api_format,
"openai:chat",
report_context,
)?,
};
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:cli" => {
convert_openai_chat_response_to_openai_cli(&canonical, report_context, false)
}
"openai:compact" => {
convert_openai_chat_response_to_openai_cli(&canonical, report_context, true)
}
"claude:cli" => convert_openai_chat_response_to_claude_chat(&canonical, report_context),
"gemini:cli" => convert_openai_chat_response_to_gemini_chat(&canonical, report_context),
_ => None,
}
}

View File

@@ -0,0 +1,578 @@
use base64::Engine as _;
use super::*;
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_cli_stream_sync_response, convert_claude_chat_response_to_openai_chat,
convert_gemini_chat_response_to_openai_chat,
};
use crate::gateway::ai_pipeline::finalize::common::{
build_generated_tool_call_id, build_local_success_outcome,
build_local_success_outcome_with_conversion_report, canonicalize_tool_arguments,
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
LocalCoreSyncFinalizeOutcome,
};
use crate::gateway::ai_pipeline::conversion::sync_chat_response_conversion_kind;
#[derive(Debug, Default)]
struct OpenAIChatChoiceState {
role: Option<String>,
content: String,
finish_reason: Option<String>,
tool_calls: BTreeMap<usize, OpenAIChatToolCallState>,
}
#[derive(Debug, Default)]
struct OpenAIChatToolCallState {
id: Option<String>,
tool_type: Option<String>,
function_name: Option<String>,
function_arguments: String,
}
pub(crate) fn maybe_build_local_openai_chat_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "openai:chat"
|| client_api_format != "openai:chat"
|| needs_conversion
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let body_json = match aggregate_openai_chat_stream_sync_response(&body_bytes) {
Some(body_json) => body_json,
None => return Ok(None),
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
pub(crate) fn maybe_build_local_openai_chat_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if provider_api_format != "openai:chat"
|| client_api_format != "openai:chat"
|| needs_conversion
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
pub(crate) fn maybe_build_local_openai_chat_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
let Some(conversion_kind) =
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let aggregated = match provider_api_format.as_str() {
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(&body_bytes),
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(&body_bytes),
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(&body_bytes),
_ => None,
};
let Some(aggregated) = aggregated else {
return Ok(None);
};
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
return Ok(None);
};
let converted = match provider_api_format.as_str() {
"claude:chat" | "claude:cli" => {
convert_claude_chat_response_to_openai_chat(&aggregated, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_chat_response_to_openai_chat(&aggregated, report_context)
}
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(&aggregated, report_context)
}
_ => None,
};
let Some(converted) = converted else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, aggregated,
)?))
}
pub(crate) fn maybe_build_local_openai_chat_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if payload.report_kind != "openai_chat_sync_finalize" || payload.status_code >= 400 {
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if client_api_format != "openai:chat" || !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
let Some(conversion_kind) =
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let converted = match provider_api_format.as_str() {
"claude:chat" | "claude:cli" => {
convert_claude_chat_response_to_openai_chat(&body_json, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_chat_response_to_openai_chat(&body_json, report_context)
}
"openai:cli" | "openai:compact" => {
convert_openai_cli_response_to_openai_chat(&body_json, report_context)
}
_ => None,
};
let Some(converted) = converted else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, body_json,
)?))
}
pub(crate) fn convert_openai_cli_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let mut text = String::new();
let mut tool_calls = Vec::new();
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
for (index, item) in output_items.iter().enumerate() {
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
for part in content {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "output_text" | "text") {
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
{
text.push_str(piece);
}
}
}
}
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = item_object
.get("call_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or_else(|| {
item_object
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
}
}));
}
"output_text" | "text" => {
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
_ => {}
}
}
}
let finish_reason = if tool_calls.is_empty() {
Some("stop")
} else {
Some("tool_calls")
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-openai-cli");
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
if text.is_empty() && !tool_calls.is_empty() {
message.insert("content".to_string(), Value::Null);
} else {
message.insert("content".to_string(), Value::String(text));
}
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
}
pub(crate) fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(body).ok()?;
let mut response_id: Option<String> = None;
let mut model: Option<String> = None;
let mut created: Option<u64> = None;
let mut usage: Option<Value> = None;
let mut choices: BTreeMap<usize, OpenAIChatChoiceState> = BTreeMap::new();
let mut saw_chunk = false;
for raw_line in text.lines() {
let line = raw_line.trim_matches('\r').trim();
if line.is_empty() || line.starts_with(':') || line.starts_with("event:") {
continue;
}
let Some(data_line) = line.strip_prefix("data:") else {
continue;
};
let data_line = data_line.trim();
if data_line.is_empty() || data_line == "[DONE]" {
continue;
}
let chunk: Value = serde_json::from_str(data_line).ok()?;
let chunk_object = chunk.as_object()?;
saw_chunk = true;
if response_id.is_none() {
response_id = chunk_object
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
if model.is_none() {
model = chunk_object
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
if created.is_none() {
created = chunk_object.get("created").and_then(Value::as_u64);
}
if let Some(u) = chunk_object.get("usage") {
usage = Some(u.clone());
}
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
continue;
};
for chunk_choice in chunk_choices {
let Some(choice_object) = chunk_choice.as_object() else {
continue;
};
let Some(index) = choice_object
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
else {
continue;
};
let state = choices.entry(index).or_default();
if let Some(finish_reason) = choice_object.get("finish_reason").and_then(Value::as_str)
{
state.finish_reason = Some(finish_reason.to_string());
}
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
continue;
};
if let Some(role) = delta.get("role").and_then(Value::as_str) {
state.role = Some(role.to_string());
}
if let Some(content) = delta.get("content").and_then(Value::as_str) {
state.content.push_str(content);
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_calls {
let Some(tool_call_object) = tool_call.as_object() else {
continue;
};
let tool_index = tool_call_object
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let tool_state = state.tool_calls.entry(tool_index).or_default();
if let Some(id) = tool_call_object.get("id").and_then(Value::as_str) {
tool_state.id = Some(id.to_string());
}
if let Some(tool_type) = tool_call_object.get("type").and_then(Value::as_str) {
tool_state.tool_type = Some(tool_type.to_string());
}
if let Some(function) =
tool_call_object.get("function").and_then(Value::as_object)
{
if let Some(name) = function.get("name").and_then(Value::as_str) {
tool_state.function_name = Some(name.to_string());
}
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
tool_state.function_arguments.push_str(arguments);
}
}
}
}
}
}
if !saw_chunk {
return None;
}
let mut response_object = Map::new();
response_object.insert(
"id".to_string(),
Value::String(response_id.unwrap_or_else(|| "chatcmpl-local-finalize".to_string())),
);
response_object.insert(
"object".to_string(),
Value::String("chat.completion".to_string()),
);
if let Some(created) = created {
response_object.insert("created".to_string(), Value::Number(created.into()));
}
if let Some(model) = model {
response_object.insert("model".to_string(), Value::String(model));
}
let mut response_choices = Vec::with_capacity(choices.len());
for (index, state) in choices {
let mut message = Map::new();
message.insert(
"role".to_string(),
Value::String(state.role.unwrap_or_else(|| "assistant".to_string())),
);
if state.tool_calls.is_empty() {
message.insert("content".to_string(), Value::String(state.content));
} else {
if state.content.is_empty() {
message.insert("content".to_string(), Value::Null);
} else {
message.insert("content".to_string(), Value::String(state.content));
}
let tool_calls = state
.tool_calls
.into_iter()
.map(|(tool_index, tool_state)| {
json!({
"index": tool_index,
"id": tool_state.id,
"type": tool_state.tool_type.unwrap_or_else(|| "function".to_string()),
"function": {
"name": tool_state.function_name,
"arguments": tool_state.function_arguments,
},
})
})
.collect::<Vec<_>>();
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
response_choices.push(json!({
"index": index,
"message": Value::Object(message),
"finish_reason": state.finish_reason,
}));
}
response_object.insert("choices".to_string(), Value::Array(response_choices));
if let Some(usage) = usage {
response_object.insert("usage".to_string(), usage);
}
Some(Value::Object(response_object))
}

View File

@@ -0,0 +1,499 @@
use serde_json::{json, Map, Value};
use crate::gateway::ai_pipeline::finalize::sse::{
encode_done_sse, encode_json_sse, map_claude_stop_reason,
};
use crate::gateway::GatewayError;
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_openai_cli_response_to_openai_chat,
};
#[derive(Default)]
pub(crate) struct ClaudeToOpenAIChatStreamState {
raw: Vec<u8>,
message_id: Option<String>,
model: Option<String>,
}
#[derive(Default)]
pub(crate) struct GeminiToOpenAIChatStreamState {
raw: Vec<u8>,
}
#[derive(Default)]
pub(crate) struct OpenAICliToOpenAIChatStreamState {
raw: Vec<u8>,
}
#[derive(Default)]
struct ClaudeToolCallState {
id: String,
name: String,
arguments: String,
}
fn canonicalize_arguments(value: Option<Value>) -> String {
match value {
Some(Value::String(text)) => text,
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
None => "{}".to_string(),
}
}
fn claude_tool_calls(content: &[Value]) -> Option<Vec<Value>> {
let mut tool_calls = Vec::new();
for (index, block) in content.iter().enumerate() {
let Some(block) = block.as_object() else {
continue;
};
if block.get("type").and_then(Value::as_str).unwrap_or("text") != "tool_use" {
continue;
}
let state = ClaudeToolCallState {
id: block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("tool_call")
.to_string(),
name: block
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
arguments: canonicalize_arguments(block.get("input").cloned()),
};
tool_calls.push(json!({
"index": index,
"id": state.id,
"type": "function",
"function": {
"name": state.name,
"arguments": state.arguments,
}
}));
}
if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
}
}
fn gemini_tool_calls(parts: &[Value]) -> Option<Vec<Value>> {
let mut tool_calls = Vec::new();
for (index, part) in parts.iter().enumerate() {
let Some(part) = part.as_object() else {
continue;
};
let Some(function_call) = part.get("functionCall").and_then(Value::as_object) else {
continue;
};
let name = function_call
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
let id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_{name}_{index}"));
tool_calls.push(json!({
"index": index,
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": canonicalize_arguments(function_call.get("args").cloned()),
}
}));
}
if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
}
}
fn build_openai_chat_chunk(
id: &str,
model: &str,
text: String,
tool_calls: Option<Vec<Value>>,
finish_reason: Option<&str>,
) -> Value {
let mut delta = Map::new();
delta.insert("role".to_string(), Value::String("assistant".to_string()));
if !text.is_empty() {
delta.insert("content".to_string(), Value::String(text));
} else if tool_calls.is_none() {
delta.insert("content".to_string(), Value::String(String::new()));
}
if let Some(tool_calls) = tool_calls {
delta.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": Value::Object(delta),
"finish_reason": finish_reason,
}]
})
}
fn claude_identity<'a>(
state: &'a ClaudeToOpenAIChatStreamState,
report_context: &'a Value,
) -> (&'a str, &'a str) {
let id = state
.message_id
.as_deref()
.unwrap_or("chatcmpl-local-stream");
let model = state
.model
.as_deref()
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
(id, model)
}
fn convert_claude_aggregated_to_openai_chunk(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
for block in content {
let block = block.as_object()?;
if block.get("type").and_then(Value::as_str).unwrap_or("text") == "text" {
if let Some(piece) = block.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
let tool_calls = claude_tool_calls(content);
let finish_reason = map_claude_stop_reason(
body.get("stop_reason").and_then(Value::as_str),
tool_calls.is_some(),
);
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-stream");
Some(build_openai_chat_chunk(
id,
model,
text,
tool_calls,
finish_reason,
))
}
fn convert_gemini_aggregated_to_openai_chunk(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let first_candidate = candidates.first()?.as_object()?;
let content = first_candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
let tool_calls = gemini_tool_calls(parts);
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"),
_ => None,
};
if tool_calls.is_some() && finish_reason.is_none_or(|value| value == "stop") {
finish_reason = Some("tool_calls");
}
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
let id = body
.get("responseId")
.and_then(Value::as_str)
.or_else(|| body.get("_v1internal_response_id").and_then(Value::as_str))
.unwrap_or("chatcmpl-local-stream");
Some(build_openai_chat_chunk(
id,
model,
text,
tool_calls,
finish_reason,
))
}
impl ClaudeToOpenAIChatStreamState {
pub(crate) fn transform_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
let Ok(text) = std::str::from_utf8(&line) else {
return Ok(Vec::new());
};
let trimmed = text.trim_matches('\r').trim();
if trimmed.is_empty() {
if self
.raw
.windows(b"\"type\":\"message_stop\"".len())
.any(|window| window == b"\"type\":\"message_stop\"")
{
return Ok(encode_done_sse());
}
return Ok(Vec::new());
}
let Some(data_line) = trimmed.strip_prefix("data:") else {
return Ok(Vec::new());
};
let data_line = data_line.trim();
if data_line.is_empty() || data_line == "[DONE]" {
return Ok(Vec::new());
}
let value: Value = match serde_json::from_str(data_line) {
Ok(value) => value,
Err(_) => return Ok(Vec::new()),
};
match value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"message_start" => {
if let Some(message) = value.get("message").and_then(Value::as_object) {
self.message_id = message
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
self.model = message
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {
"role": "assistant"
},
"finish_reason": Value::Null
}]
}),
)
}
"content_block_delta" => {
let Some(delta) = value.get("delta").and_then(Value::as_object) else {
return Ok(Vec::new());
};
if delta.get("type").and_then(Value::as_str) != Some("text_delta") {
return Ok(Vec::new());
}
let Some(piece) = delta.get("text").and_then(Value::as_str) else {
return Ok(Vec::new());
};
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&build_openai_chat_chunk(id, model, piece.to_string(), None, None),
)
}
"content_block_start" => {
let Some(block) = value.get("content_block").and_then(Value::as_object) else {
return Ok(Vec::new());
};
if block.get("type").and_then(Value::as_str) != Some("tool_use") {
return Ok(Vec::new());
}
let call = json!({
"index": value.get("index").and_then(Value::as_u64).unwrap_or(0),
"id": block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("tool_call"),
"type": "function",
"function": {
"name": block.get("name").and_then(Value::as_str).unwrap_or("unknown"),
"arguments": canonicalize_arguments(block.get("input").cloned()),
}
});
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&build_openai_chat_chunk(id, model, String::new(), Some(vec![call]), None),
)
}
"message_delta" => {
let Some(delta) = value.get("delta").and_then(Value::as_object) else {
return Ok(Vec::new());
};
let Some(finish_reason) = map_claude_stop_reason(
delta.get("stop_reason").and_then(Value::as_str),
delta.get("stop_reason").and_then(Value::as_str) == Some("tool_use"),
) else {
return Ok(Vec::new());
};
let (id, model) = claude_identity(self, report_context);
encode_json_sse(
None,
&json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {},
"finish_reason": finish_reason
}]
}),
)
}
_ => Ok(Vec::new()),
}
}
pub(crate) fn finish(&mut self) -> Vec<u8> {
if self.raw.is_empty() {
return Vec::new();
}
let aggregated = aggregate_claude_stream_sync_response(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Vec::new();
};
let Some(chunk) = convert_claude_aggregated_to_openai_chunk(&aggregated, &Value::Null)
else {
return Vec::new();
};
let mut out = encode_json_sse(None, &chunk).unwrap_or_default();
out.extend(encode_done_sse());
out
}
}
impl GeminiToOpenAIChatStreamState {
pub(crate) fn transform_line(
&mut self,
_report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
Ok(Vec::new())
}
pub(crate) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> {
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated = aggregate_gemini_stream_sync_response(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(chunk) = convert_gemini_aggregated_to_openai_chunk(&aggregated, report_context)
else {
return Ok(Vec::new());
};
let mut out = encode_json_sse(None, &chunk)?;
out.extend(encode_done_sse());
Ok(out)
}
}
impl OpenAICliToOpenAIChatStreamState {
pub(crate) fn transform_line(
&mut self,
_report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
Ok(Vec::new())
}
pub(crate) fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, GatewayError> {
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated = crate::gateway::ai_pipeline::finalize::standard::aggregate_openai_cli_stream_sync_response(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(chat_response) =
convert_openai_cli_response_to_openai_chat(&aggregated, report_context)
else {
return Ok(Vec::new());
};
let Some(chat_object) = chat_response.as_object() else {
return Ok(Vec::new());
};
let Some(choice) = chat_object
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(Value::as_object)
else {
return Ok(Vec::new());
};
let Some(message) = choice.get("message").and_then(Value::as_object) else {
return Ok(Vec::new());
};
let content = message
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let tool_calls = message.get("tool_calls").and_then(Value::as_array).cloned();
let finish_reason = choice.get("finish_reason").and_then(Value::as_str);
let id = chat_object
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-stream");
let model = chat_object
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown");
let chunk = build_openai_chat_chunk(id, model, content, tool_calls, finish_reason);
let mut out = encode_json_sse(None, &chunk)?;
out.extend(encode_done_sse());
Ok(out)
}
}

View File

@@ -0,0 +1,692 @@
use base64::Engine as _;
use super::*;
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli,
};
use crate::gateway::ai_pipeline::finalize::common::{
build_local_success_outcome, build_local_success_outcome_with_conversion_report,
canonicalize_tool_arguments, local_finalize_allows_envelope,
unwrap_local_finalize_response_value, LocalCoreSyncFinalizeOutcome,
};
use crate::gateway::ai_pipeline::conversion::sync_cli_response_conversion_kind;
pub(crate) fn maybe_build_local_openai_cli_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if let Some(response) =
maybe_build_local_openai_cli_direct_stream_sync_response(trace_id, decision, payload)?
{
return Ok(Some(response));
}
if let Some(response) = maybe_build_local_openai_cli_openai_family_stream_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
maybe_build_local_openai_cli_direct_sync_response(trace_id, decision, payload)
}
pub(crate) fn maybe_build_local_openai_cli_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if let Some(response) =
maybe_build_local_openai_cli_antigravity_cross_format_stream_sync_response(
trace_id, decision, payload,
)?
{
return Ok(Some(response));
}
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 _has_envelope = report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false);
if !matches!(client_api_format.as_str(), "openai:cli" | "openai:compact")
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(conversion_kind) =
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let aggregated = match provider_api_format.as_str() {
"openai:cli" | "openai:compact" => aggregate_openai_cli_stream_sync_response(&body_bytes),
"claude:chat" | "claude:cli" => aggregate_claude_stream_sync_response(&body_bytes),
"gemini:chat" | "gemini:cli" => aggregate_gemini_stream_sync_response(&body_bytes),
_ => None,
};
let Some(aggregated) = aggregated else {
return Ok(None);
};
let Some(aggregated) = unwrap_local_finalize_response_value(aggregated, report_context)? else {
return Ok(None);
};
let converted = match provider_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(aggregated.clone()),
"claude:chat" | "claude:cli" => {
convert_claude_cli_response_to_openai_cli(&aggregated, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_cli_response_to_openai_cli(&aggregated, report_context)
}
_ => None,
};
let Some(converted) = converted else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, aggregated,
)?))
}
pub(crate) fn maybe_build_local_openai_cli_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if let Some(response) = maybe_build_local_openai_cli_antigravity_cross_format_sync_response(
trace_id, decision, payload,
)? {
return Ok(Some(response));
}
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if !matches!(client_api_format.as_str(), "openai:cli" | "openai:compact")
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(conversion_kind) =
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)
else {
return Ok(None);
};
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let converted = match provider_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(body_json.clone()),
"claude:chat" | "claude:cli" => {
convert_claude_cli_response_to_openai_cli(&body_json, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_cli_response_to_openai_cli(&body_json, report_context)
}
_ => None,
};
let Some(converted) = converted else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id, decision, payload, converted, body_json,
)?))
}
fn maybe_build_local_openai_cli_direct_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if !local_finalize_allows_envelope(report_context)
|| !is_openai_cli_family_api_format(provider_api_format.as_str())
|| !is_openai_cli_family_api_format(client_api_format.as_str())
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
fn maybe_build_local_openai_cli_direct_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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 needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
if !local_finalize_allows_envelope(report_context) {
return Ok(None);
}
if !matches!(
provider_api_format.as_str(),
"openai:cli" | "openai:compact"
) || provider_api_format != client_api_format
|| needs_conversion
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let body_json = match aggregate_openai_cli_stream_sync_response(&body_bytes) {
Some(body_json) => body_json,
None => return Ok(None),
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
fn maybe_build_local_openai_cli_openai_family_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if !local_finalize_allows_envelope(report_context)
|| !is_openai_cli_family_api_format(provider_api_format.as_str())
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|| provider_api_format == client_api_format
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(body_json) = aggregate_openai_cli_stream_sync_response(&body_bytes) else {
return Ok(None);
};
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)? else {
return Ok(None);
};
Ok(Some(build_local_success_outcome(
trace_id, decision, payload, body_json,
)?))
}
fn maybe_build_local_openai_cli_antigravity_cross_format_stream_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if provider_api_format != "gemini:cli"
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|| !is_antigravity_v1internal_envelope(report_context)
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(aggregated) = aggregate_gemini_stream_sync_response(&body_bytes) else {
return Ok(None);
};
let Some(provider_body_json) =
unwrap_cli_conversion_response_value(aggregated, report_context)?
else {
return Ok(None);
};
let Some(converted) =
convert_gemini_cli_response_to_openai_cli(&provider_body_json, report_context)
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id,
decision,
payload,
converted,
provider_body_json,
)?))
}
fn maybe_build_local_openai_cli_antigravity_cross_format_sync_response(
trace_id: &str,
decision: &GatewayControlDecision,
payload: &GatewaySyncReportRequest,
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
if !matches!(
payload.report_kind.as_str(),
"openai_cli_sync_finalize" | "openai_compact_sync_finalize"
) || payload.status_code >= 400
{
return Ok(None);
}
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(None);
};
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();
if provider_api_format != "gemini:cli"
|| !is_openai_cli_family_api_format(client_api_format.as_str())
|| !is_antigravity_v1internal_envelope(report_context)
|| !local_finalize_allows_envelope(report_context)
{
return Ok(None);
}
let Some(body_json) = payload.body_json.as_ref() else {
return Ok(None);
};
let Some(provider_body_json) =
unwrap_cli_conversion_response_value(body_json.clone(), report_context)?
else {
return Ok(None);
};
let Some(converted) =
convert_gemini_cli_response_to_openai_cli(&provider_body_json, report_context)
else {
return Ok(None);
};
Ok(Some(build_local_success_outcome_with_conversion_report(
trace_id,
decision,
payload,
converted,
provider_body_json,
)?))
}
fn unwrap_cli_conversion_response_value(
data: Value,
report_context: &Value,
) -> Result<Option<Value>, GatewayError> {
if !is_antigravity_v1internal_envelope(report_context) {
return unwrap_local_finalize_response_value(data, report_context);
}
let mut unwrapped = if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
let mut response = response.clone();
if let Some(response_id) = data.get("responseId").cloned() {
response
.entry("responseId".to_string())
.or_insert(response_id);
}
Value::Object(response)
} else {
data
};
if let Some(object) = unwrapped.as_object_mut() {
if !object.contains_key("responseId") {
if let Some(response_id) = object.get("_v1internal_response_id").cloned() {
object.insert("responseId".to_string(), response_id);
}
}
}
Ok(Some(unwrapped))
}
fn is_antigravity_v1internal_envelope(report_context: &Value) -> bool {
report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
&& report_context
.get("envelope_name")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("antigravity:v1internal"))
}
fn is_openai_cli_family_api_format(api_format: &str) -> bool {
matches!(api_format, "openai:cli" | "openai:compact")
}
pub(crate) fn build_openai_cli_response(
response_id: &str,
model: &str,
text: &str,
function_calls: Vec<Value>,
prompt_tokens: u64,
output_tokens: u64,
total_tokens: u64,
) -> Value {
let mut output = Vec::new();
if !text.is_empty() {
output.push(json!({
"type": "message",
"id": format!("{response_id}_msg"),
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": text,
"annotations": []
}]
}));
}
output.extend(function_calls);
json!({
"id": response_id,
"object": "response",
"status": "completed",
"model": model,
"output": output,
"usage": {
"input_tokens": prompt_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
}
})
}
pub(crate) fn convert_openai_chat_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
compact: bool,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut text = String::new();
match message.get("content") {
Some(Value::String(value)) => text.push_str(value),
Some(Value::Array(parts)) => {
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
}
Some(Value::Null) | None => {}
_ => return None,
}
let mut function_calls = Vec::new();
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_call_values {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
function_calls.push(json!({
"type": "function_call",
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"name": tool_name,
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
}));
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let response_id = if compact {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
} else {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
};
let model = body
.get("model")
.and_then(Value::as_str)
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown");
Some(build_openai_cli_response(
&response_id,
model,
&text,
function_calls,
prompt_tokens,
output_tokens,
total_tokens,
))
}
pub(crate) fn aggregate_openai_cli_stream_sync_response(body: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(body).ok()?;
for raw_line in text.lines() {
let line = raw_line.trim_matches('\r').trim();
if line.is_empty() || line.starts_with(':') || line.starts_with("event:") {
continue;
}
let Some(data_line) = line.strip_prefix("data:") else {
continue;
};
let data_line = data_line.trim();
if data_line.is_empty() || data_line == "[DONE]" {
continue;
}
let event: Value = serde_json::from_str(data_line).ok()?;
let event_type = event
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if event_type == "response.completed" {
let response = event.get("response")?.as_object()?.clone();
return Some(Value::Object(response));
}
}
None
}

View File

@@ -0,0 +1,44 @@
use serde_json::{json, Value};
use crate::gateway::ai_pipeline::finalize::sse::encode_json_sse;
use crate::gateway::GatewayError;
#[derive(Default)]
pub(crate) struct BufferedCliConversionStreamState {
raw: Vec<u8>,
}
impl BufferedCliConversionStreamState {
pub(crate) fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, GatewayError> {
self.raw.extend_from_slice(&line);
Ok(Vec::new())
}
pub(crate) fn finish<AggregateFn, ConvertFn>(
&mut self,
report_context: &Value,
aggregate: AggregateFn,
convert: ConvertFn,
) -> Result<Vec<u8>, GatewayError>
where
AggregateFn: Fn(&[u8]) -> Option<Value>,
ConvertFn: Fn(&Value, &Value) -> Option<Value>,
{
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated = aggregate(&self.raw);
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(response) = convert(&aggregated, report_context) else {
return Ok(Vec::new());
};
let event = json!({
"type": "response.completed",
"response": response,
});
encode_json_sse(Some("response.completed"), &event)
}
}

View File

@@ -0,0 +1,347 @@
use serde_json::{json, Value};
use crate::gateway::ai_pipeline::finalize::standard::{
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
convert_standard_chat_response, convert_standard_cli_response,
};
use crate::gateway::ai_pipeline::finalize::sse::{encode_done_sse, encode_json_sse};
use crate::gateway::ai_pipeline::private_response::transform_provider_private_stream_line as transform_envelope_line;
use crate::gateway::ai_pipeline::private_surfaces::provider_adaptation_should_unwrap_stream_envelope;
use crate::gateway::GatewayError;
#[derive(Default)]
pub(crate) struct BufferedStandardConversionStreamState {
raw: Vec<u8>,
}
impl BufferedStandardConversionStreamState {
pub(crate) fn transform_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
if should_unwrap_envelope(report_context) {
self.raw
.extend(transform_envelope_line(report_context, line)?);
} else {
self.raw.extend_from_slice(&line);
}
Ok(Vec::new())
}
pub(crate) fn finish_as_chat(
&mut self,
report_context: &Value,
) -> Result<Vec<u8>, GatewayError> {
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();
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated =
aggregate_standard_chat_stream_sync_response(&self.raw, provider_api_format.as_str());
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(converted) = convert_standard_chat_response(
&aggregated,
provider_api_format.as_str(),
client_api_format.as_str(),
report_context,
) else {
return Ok(Vec::new());
};
emit_chat_stream_for_client_format(&converted, client_api_format.as_str())
}
pub(crate) fn finish_as_cli(
&mut self,
report_context: &Value,
) -> Result<Vec<u8>, GatewayError> {
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();
if self.raw.is_empty() {
return Ok(Vec::new());
}
let aggregated =
aggregate_standard_cli_stream_sync_response(&self.raw, provider_api_format.as_str());
self.raw.clear();
let Some(aggregated) = aggregated else {
return Ok(Vec::new());
};
let Some(converted) = convert_standard_cli_response(
&aggregated,
provider_api_format.as_str(),
client_api_format.as_str(),
report_context,
) else {
return Ok(Vec::new());
};
emit_cli_stream_for_client_format(&converted, client_api_format.as_str())
}
}
fn should_unwrap_envelope(report_context: &Value) -> bool {
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format)
}
fn emit_chat_stream_for_client_format(
response_body: &Value,
client_api_format: &str,
) -> Result<Vec<u8>, GatewayError> {
match client_api_format {
"openai:chat" => emit_openai_chat_stream(response_body),
"claude:chat" | "claude:cli" => emit_claude_message_stream(response_body),
"gemini:chat" | "gemini:cli" => encode_json_sse(None, response_body),
_ => Ok(Vec::new()),
}
}
fn emit_cli_stream_for_client_format(
response_body: &Value,
client_api_format: &str,
) -> Result<Vec<u8>, GatewayError> {
match client_api_format {
"openai:cli" | "openai:compact" => encode_json_sse(
Some("response.completed"),
&json!({
"type": "response.completed",
"response": response_body,
}),
),
"claude:cli" => emit_claude_message_stream(response_body),
"gemini:cli" => encode_json_sse(None, response_body),
_ => Ok(Vec::new()),
}
}
fn emit_openai_chat_stream(response_body: &Value) -> Result<Vec<u8>, GatewayError> {
let body = match response_body.as_object() {
Some(body) => body,
None => return Ok(Vec::new()),
};
let choice = match body
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(Value::as_object)
{
Some(choice) => choice,
None => return Ok(Vec::new()),
};
let message = match choice.get("message").and_then(Value::as_object) {
Some(message) => message,
None => return Ok(Vec::new()),
};
let content = match extract_openai_chat_content_text(message.get("content")) {
Some(content) => content,
None => return Ok(Vec::new()),
};
let mut delta = serde_json::Map::new();
delta.insert("role".to_string(), Value::String("assistant".to_string()));
if !content.is_empty() {
delta.insert("content".to_string(), Value::String(content));
} else if message.get("tool_calls").is_none() {
delta.insert("content".to_string(), Value::String(String::new()));
}
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
delta.insert("tool_calls".to_string(), Value::Array(tool_calls.clone()));
}
let chunk = json!({
"id": body.get("id").cloned().unwrap_or_else(|| Value::String("chatcmpl-local-stream".to_string())),
"object": "chat.completion.chunk",
"model": body.get("model").cloned().unwrap_or_else(|| Value::String("unknown".to_string())),
"choices": [{
"index": choice.get("index").cloned().unwrap_or_else(|| Value::from(0_u64)),
"delta": Value::Object(delta),
"finish_reason": choice.get("finish_reason").cloned().unwrap_or(Value::Null),
}]
});
let mut out = encode_json_sse(None, &chunk)?;
out.extend(encode_done_sse());
Ok(out)
}
fn emit_claude_message_stream(response_body: &Value) -> Result<Vec<u8>, GatewayError> {
let body = match response_body.as_object() {
Some(body) => body,
None => return Ok(Vec::new()),
};
let message_id = body
.get("id")
.cloned()
.unwrap_or_else(|| Value::String("msg-local-stream".to_string()));
let model = body
.get("model")
.cloned()
.unwrap_or_else(|| Value::String("unknown".to_string()));
let content_blocks = match body.get("content").and_then(Value::as_array) {
Some(content) => content,
None => return Ok(Vec::new()),
};
let mut out = encode_json_sse(
Some("message_start"),
&json!({
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"model": model,
"content": [],
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
}
}),
)?;
for (index, block) in content_blocks.iter().enumerate() {
let Some(block_object) = block.as_object() else {
continue;
};
match block_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("text")
{
"text" => {
out.extend(encode_json_sse(
Some("content_block_start"),
&json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "text",
"text": "",
}
}),
)?);
if let Some(text) = block_object.get("text").and_then(Value::as_str) {
if !text.is_empty() {
out.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "text_delta",
"text": text,
}
}),
)?);
}
}
out.extend(encode_json_sse(
Some("content_block_stop"),
&json!({
"type": "content_block_stop",
"index": index,
}),
)?);
}
"tool_use" => {
out.extend(encode_json_sse(
Some("content_block_start"),
&json!({
"type": "content_block_start",
"index": index,
"content_block": block_object,
}),
)?);
out.extend(encode_json_sse(
Some("content_block_stop"),
&json!({
"type": "content_block_stop",
"index": index,
}),
)?);
}
_ => {}
}
}
let mut delta = serde_json::Map::new();
delta.insert(
"stop_reason".to_string(),
body.get("stop_reason").cloned().unwrap_or(Value::Null),
);
if let Some(stop_sequence) = body.get("stop_sequence").cloned() {
delta.insert("stop_sequence".to_string(), stop_sequence);
}
let mut message_delta = serde_json::Map::new();
message_delta.insert(
"type".to_string(),
Value::String("message_delta".to_string()),
);
message_delta.insert("delta".to_string(), Value::Object(delta));
if let Some(usage) = body.get("usage").cloned() {
message_delta.insert("usage".to_string(), usage);
}
out.extend(encode_json_sse(
Some("message_delta"),
&Value::Object(message_delta),
)?);
out.extend(encode_json_sse(
Some("message_stop"),
&json!({
"type": "message_stop",
}),
)?);
Ok(out)
}
fn extract_openai_chat_content_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::Null => Some(String::new()),
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let mut text = String::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
}
}
Some(text)
}
_ => None,
}
}

View File

@@ -0,0 +1,477 @@
use serde_json::json;
use super::maybe_build_local_stream_rewriter;
#[test]
fn antigravity_stream_rewriter_unwraps_and_injects_tool_ids() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:cli",
"client_api_format": "gemini:cli",
"envelope_name": "antigravity:v1internal",
"needs_conversion": false,
"mapped_model": "claude-sonnet-4-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_123\"}\n\n",
)
.expect("rewrite should succeed");
let output_text = String::from_utf8(output).expect("text should be utf8");
assert!(output_text.contains("\"_v1internal_response_id\":\"resp_123\""));
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
assert!(output_text.contains("\"modelVersion\":\"claude-sonnet-4-5\""));
}
#[test]
fn gemini_cli_v1internal_stream_rewriter_unwraps_response_object() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:cli",
"client_api_format": "gemini:cli",
"envelope_name": "gemini_cli:v1internal",
"needs_conversion": false,
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello Gemini CLI\"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-cli-2.5\"}}\n\n",
)
.expect("rewrite should succeed");
let output_text = String::from_utf8(output).expect("text should be utf8");
assert_eq!(
output_text,
"data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello Gemini CLI\"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-cli-2.5\"}\n\n"
);
}
#[test]
fn claude_to_openai_chat_stream_rewriter_converts_text_deltas() {
let report_context = json!({
"provider_api_format": "claude:chat",
"client_api_format": "openai:chat",
"needs_conversion": true,
"mapped_model": "claude-sonnet-4-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-sonnet-4-5\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let output_text = String::from_utf8(output).expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Hello\""));
assert!(output_text.contains("\"finish_reason\":\"stop\""));
assert!(output_text.contains("data: [DONE]"));
}
#[test]
fn claude_to_openai_chat_stream_rewriter_converts_tool_use_to_tool_calls() {
let report_context = json!({
"provider_api_format": "claude:chat",
"client_api_format": "openai:chat",
"needs_conversion": true,
"mapped_model": "claude-sonnet-4-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool_claude_chat_stream_123\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Need a tool.\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tool_123\",\"name\":\"get_weather\",\"input\":{\"location\":\"Tokyo\"}}}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let output_text = String::from_utf8(output).expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"tool_calls\":[{"));
assert!(output_text.contains("\"id\":\"tool_123\""));
assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\\\"location\\\":\\\"Tokyo\\\""));
assert!(output_text.contains("\"finish_reason\":\"tool_calls\""));
assert!(output_text.contains("data: [DONE]"));
}
#[test]
fn gemini_to_openai_chat_stream_rewriter_buffers_and_converts_text() {
let report_context = json!({
"provider_api_format": "gemini:chat",
"client_api_format": "openai:chat",
"needs_conversion": true,
"mapped_model": "gemini-2.5-pro",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let first = rewriter
.push_chunk(
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
)
.expect("rewrite should succeed");
assert!(first.is_empty());
let second = rewriter
.push_chunk(
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Gemini\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
)
.expect("rewrite should succeed");
assert!(second.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Gemini\""));
assert!(output_text.contains("\"finish_reason\":\"stop\""));
assert!(output_text.contains("data: [DONE]"));
}
#[test]
fn gemini_to_openai_chat_stream_rewriter_buffers_and_converts_function_call() {
let report_context = json!({
"provider_api_format": "gemini:chat",
"client_api_format": "openai:chat",
"needs_conversion": true,
"mapped_model": "gemini-2.5-pro",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"responseId\":\"resp_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Need a tool.\""));
assert!(output_text.contains("\"tool_calls\":[{"));
assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\\\"city\\\":\\\"SF\\\""));
assert!(output_text.contains("\"finish_reason\":\"tool_calls\""));
assert!(output_text.contains("data: [DONE]"));
}
#[test]
fn openai_cli_to_openai_chat_stream_rewriter_buffers_and_converts_completed_event() {
let report_context = json!({
"provider_api_format": "openai:cli",
"client_api_format": "openai:chat",
"needs_conversion": true,
"mapped_model": "gpt-5.4",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_cli_stream_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"id\":\"msg_cli_stream_123\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello Codex\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Hello Codex\""));
assert!(output_text.contains("\"finish_reason\":\"stop\""));
assert!(output_text.contains("data: [DONE]"));
}
#[test]
fn antigravity_gemini_to_openai_chat_stream_rewriter_unwraps_and_converts_function_call() {
let report_context = json!({
"provider_api_format": "gemini:chat",
"client_api_format": "openai:chat",
"needs_conversion": true,
"has_envelope": true,
"envelope_name": "antigravity:v1internal",
"mapped_model": "claude-sonnet-4-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"response\":{\"responseId\":\"resp_antigravity_chat_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_antigravity_chat_tool_123\"}\n\n",
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"tool_calls\""));
assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\"finish_reason\":\"tool_calls\""));
}
#[test]
fn antigravity_gemini_to_openai_cli_stream_rewriter_unwraps_and_converts_function_call() {
let report_context = json!({
"provider_api_format": "gemini:cli",
"client_api_format": "openai:cli",
"needs_conversion": true,
"envelope_name": "antigravity:v1internal",
"mapped_model": "claude-sonnet-4-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"response\":{\"responseId\":\"resp_antigravity_cli_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}},\"responseId\":\"resp_antigravity_cli_tool_123\"}\n\n",
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"name\":\"get_weather\""));
}
#[test]
fn gemini_to_openai_cli_stream_rewriter_buffers_and_converts_to_completed_event() {
let report_context = json!({
"provider_api_format": "gemini:cli",
"client_api_format": "openai:cli",
"needs_conversion": true,
"mapped_model": "gemini-2.5-pro",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let first = rewriter
.push_chunk(
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello \"}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\"}\n\n",
)
.expect("rewrite should succeed");
assert!(first.is_empty());
let second = rewriter
.push_chunk(
b"data: {\"responseId\":\"resp_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Gemini CLI\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n",
)
.expect("rewrite should succeed");
assert!(second.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"object\":\"response\""));
assert!(output_text.contains("\"text\":\"Gemini CLI\""));
assert!(output_text.contains("\"total_tokens\":5"));
}
#[test]
fn claude_to_openai_cli_stream_rewriter_buffers_and_converts_to_completed_event() {
let report_context = json!({
"provider_api_format": "claude:cli",
"client_api_format": "openai:cli",
"needs_conversion": true,
"mapped_model": "claude-sonnet-4-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-sonnet-4-5\"}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello Claude CLI\"}}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":2,\"output_tokens\":3}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"object\":\"response\""));
assert!(output_text.contains("\"text\":\"Hello Claude CLI\""));
assert!(output_text.contains("\"total_tokens\":5"));
}
#[test]
fn claude_to_openai_cli_stream_rewriter_converts_tool_use_to_function_call() {
let report_context = json!({
"provider_api_format": "claude:cli",
"client_api_format": "openai:cli",
"needs_conversion": true,
"mapped_model": "claude-sonnet-4-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool_123\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Running tool.\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"tool_123\",\"name\":\"read_file\",\"input\":{\"path\":\"/tmp/test.txt\"}}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":4,\"output_tokens\":6}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"call_id\":\"tool_123\""));
assert!(output_text.contains("\"name\":\"read_file\""));
assert!(output_text.contains("\\\"path\\\":\\\"/tmp/test.txt\\\""));
}
#[test]
fn gemini_to_openai_cli_stream_rewriter_converts_function_call_to_completed_event() {
let report_context = json!({
"provider_api_format": "gemini:cli",
"client_api_format": "openai:cli",
"needs_conversion": true,
"mapped_model": "gemini-2.5-pro",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"responseId\":\"resp_tool_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"location\":\"Tokyo\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n",
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\\\"location\\\":\\\"Tokyo\\\""));
}
#[test]
fn gemini_to_openai_compact_stream_rewriter_converts_function_call_to_completed_event() {
let report_context = json!({
"provider_api_format": "gemini:cli",
"client_api_format": "openai:compact",
"needs_conversion": true,
"mapped_model": "gemini-2.5-pro",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"responseId\":\"resp_tool_compact_123\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Need a tool.\"},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"location\":\"Tokyo\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-pro\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}\n\n",
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"type\":\"response.completed\""));
assert!(output_text.contains("\"type\":\"function_call\""));
assert!(output_text.contains("\"name\":\"get_weather\""));
assert!(output_text.contains("\\\"location\\\":\\\"Tokyo\\\""));
}
#[test]
fn openai_chat_to_claude_chat_stream_rewriter_converts_via_standard_matrix() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "claude:chat",
"needs_conversion": true,
"mapped_model": "gpt-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
concat!(
"data: {\"id\":\"chatcmpl_std_claude_123\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello Claude\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl_std_claude_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n",
"data: [DONE]\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("event: message_start"));
assert!(output_text.contains("event: content_block_delta"));
assert!(output_text.contains("\"text\":\"Hello Claude\""));
assert!(output_text.contains("event: message_stop"));
}
#[test]
fn openai_chat_to_gemini_cli_stream_rewriter_converts_via_standard_matrix() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "gemini:cli",
"needs_conversion": true,
"mapped_model": "gpt-5",
});
let mut rewriter =
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
let output = rewriter
.push_chunk(
concat!(
"data: {\"id\":\"chatcmpl_std_gemini_cli_123\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello Gemini CLI\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl_std_gemini_cli_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n",
"data: [DONE]\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(output.is_empty());
let output_text = String::from_utf8(rewriter.finish().expect("finish should succeed"))
.expect("utf8 should decode");
assert!(output_text.contains("\"responseId\":\"chatcmpl_std_gemini_cli_123\""));
assert!(output_text.contains("\"candidates\""));
assert!(output_text.contains("\"text\":\"Hello Gemini CLI\""));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
pub(crate) mod conversion;
pub(crate) mod finalize;
pub(crate) mod planner;
pub(crate) mod private_response;
pub(crate) mod private_surfaces;
pub(crate) mod runtime;

View File

@@ -0,0 +1,294 @@
use tracing::warn;
use crate::gateway::provider_transport::resolve_transport_proxy_snapshot;
use crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate;
use crate::gateway::AppState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum TunnelOwnerAffinityBucket {
LocalTunnel = 0,
Neutral = 1,
RemoteTunnel = 2,
}
pub(crate) async fn prefer_local_tunnel_owner_candidates(
state: &AppState,
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
) -> Vec<GatewayMinimalCandidateSelectionCandidate> {
let mut ranked = Vec::with_capacity(candidates.len());
for (original_index, candidate) in candidates.into_iter().enumerate() {
let bucket = resolve_candidate_tunnel_owner_affinity(state, &candidate).await;
ranked.push((bucket, original_index, candidate));
}
ranked.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
ranked
.into_iter()
.map(|(_, _, candidate)| candidate)
.collect()
}
async fn resolve_candidate_tunnel_owner_affinity(
state: &AppState,
candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> TunnelOwnerAffinityBucket {
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(transport)) => transport,
Ok(None) => return TunnelOwnerAffinityBucket::Neutral,
Err(error) => {
warn!(
provider_id = %candidate.provider_id,
endpoint_id = %candidate.endpoint_id,
key_id = %candidate.key_id,
error = ?error,
"failed to load provider transport while evaluating tunnel owner affinity"
);
return TunnelOwnerAffinityBucket::Neutral;
}
};
let Some(proxy) = resolve_transport_proxy_snapshot(&transport) else {
return TunnelOwnerAffinityBucket::Neutral;
};
if proxy.enabled == Some(false) {
return TunnelOwnerAffinityBucket::Neutral;
}
let Some(node_id) = proxy
.node_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return TunnelOwnerAffinityBucket::Neutral;
};
if state.tunnel.has_local_proxy(node_id) {
return TunnelOwnerAffinityBucket::LocalTunnel;
}
match state
.tunnel
.lookup_attachment_owner(state.data.as_ref(), node_id)
.await
{
Ok(Some(owner)) if owner.gateway_instance_id == state.tunnel.local_instance_id() => {
TunnelOwnerAffinityBucket::LocalTunnel
}
Ok(Some(_)) => TunnelOwnerAffinityBucket::RemoteTunnel,
Ok(None) => TunnelOwnerAffinityBucket::Neutral,
Err(error) => {
warn!(
node_id = node_id,
error = %error,
"failed to load tunnel attachment owner while evaluating scheduler affinity"
);
TunnelOwnerAffinityBucket::Neutral
}
}
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use aether_data::repository::provider_catalog::{
InMemoryProviderCatalogReadRepository, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use super::*;
use crate::gateway::tunnel::TunnelAttachmentRecord;
use crate::gateway::GatewayDataState;
fn sample_candidate(
endpoint_id: &str,
key_id: &str,
) -> GatewayMinimalCandidateSelectionCandidate {
GatewayMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
provider_name: "provider-1".to_string(),
provider_type: "custom".to_string(),
provider_priority: 0,
endpoint_id: endpoint_id.to_string(),
endpoint_api_format: "openai:chat".to_string(),
key_id: key_id.to_string(),
key_name: key_id.to_string(),
key_auth_type: "api_key".to_string(),
key_internal_priority: 0,
key_global_priority_for_format: Some(0),
key_capabilities: None,
model_id: "model-1".to_string(),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
mapping_matched_model: None,
}
}
fn sample_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-1".to_string(),
"provider-1".to_string(),
Some("https://provider.example".to_string()),
"custom".to_string(),
)
.expect("provider should build")
.with_transport_fields(true, false, false, None, None, None, None, None, None)
}
fn sample_endpoint(id: &str) -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
id.to_string(),
"provider-1".to_string(),
"openai:chat".to_string(),
Some("openai".to_string()),
Some("chat".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://api.provider.example".to_string(),
None,
None,
None,
None,
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_key(id: &str, node_id: &str) -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
id.to_string(),
"provider-1".to_string(),
id.to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(json!(["openai:chat"])),
"plain-upstream-key".to_string(),
None,
None,
Some(json!({"openai:chat": 1})),
None,
None,
Some(json!({
"enabled": true,
"mode": "tunnel",
"node_id": node_id,
})),
None,
)
.expect("key transport should build")
}
fn tunnel_attachment_key(node_id: &str) -> String {
format!("tunnel.attachments.{node_id}")
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[tokio::test]
async fn prefers_local_tunnel_owner_candidates_before_remote_tunnel_candidates() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider()],
vec![
sample_endpoint("endpoint-remote"),
sample_endpoint("endpoint-local"),
],
vec![
sample_key("key-remote", "node-remote"),
sample_key("key-local", "node-local"),
],
);
let observed_at_unix_secs = current_unix_secs();
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
"development-key",
)
.with_system_config_values_for_tests(vec![
(
tunnel_attachment_key("node-remote"),
serde_json::to_value(TunnelAttachmentRecord {
gateway_instance_id: "gateway-b".to_string(),
relay_base_url: "http://gateway-b:8080".to_string(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("remote attachment should serialize"),
),
(
tunnel_attachment_key("node-local"),
serde_json::to_value(TunnelAttachmentRecord {
gateway_instance_id: "gateway-a".to_string(),
relay_base_url: "http://gateway-a:8080".to_string(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("local attachment should serialize"),
),
]);
let state = AppState::new("http://127.0.0.1:1")
.expect("state should build")
.with_data_state_for_tests(data_state)
.with_tunnel_identity_for_tests("gateway-a", Some("http://gateway-a:8080"));
let reordered = prefer_local_tunnel_owner_candidates(
&state,
vec![
sample_candidate("endpoint-remote", "key-remote"),
sample_candidate("endpoint-local", "key-local"),
],
)
.await;
assert_eq!(reordered[0].endpoint_id, "endpoint-local");
assert_eq!(reordered[1].endpoint_id, "endpoint-remote");
}
#[tokio::test]
async fn leaves_candidate_order_unchanged_when_transport_has_no_tunnel_proxy() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider()],
vec![sample_endpoint("endpoint-a"), sample_endpoint("endpoint-b")],
vec![sample_key("key-a", ""), sample_key("key-b", "")],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
"development-key",
);
let state = AppState::new("http://127.0.0.1:1")
.expect("state should build")
.with_data_state_for_tests(data_state)
.with_tunnel_identity_for_tests("gateway-a", Some("http://gateway-a:8080"));
let reordered = prefer_local_tunnel_owner_candidates(
&state,
vec![
sample_candidate("endpoint-a", "key-a"),
sample_candidate("endpoint-b", "key-b"),
],
)
.await;
assert_eq!(reordered[0].endpoint_id, "endpoint-a");
assert_eq!(reordered[1].endpoint_id, "endpoint-b");
}
}

View File

@@ -0,0 +1,57 @@
use axum::body::Bytes;
use base64::Engine as _;
use crate::gateway::headers::is_json_request;
pub(crate) const GEMINI_FILES_GET_PLAN_KIND: &str = "gemini_files_get";
pub(crate) const GEMINI_FILES_UPLOAD_PLAN_KIND: &str = "gemini_files_upload";
pub(crate) const GEMINI_FILES_LIST_PLAN_KIND: &str = "gemini_files_list";
pub(crate) const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete";
pub(crate) const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download";
pub(crate) const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
pub(crate) const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
pub(crate) const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";
pub(crate) const OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND: &str = "openai_video_delete_sync";
pub(crate) const GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "gemini_video_create_sync";
pub(crate) const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
pub(crate) const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
pub(crate) const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
pub(crate) const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
pub(crate) const OPENAI_CLI_STREAM_PLAN_KIND: &str = "openai_cli_stream";
pub(crate) const OPENAI_COMPACT_STREAM_PLAN_KIND: &str = "openai_compact_stream";
pub(crate) const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
pub(crate) const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
pub(crate) const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
pub(crate) const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
pub(crate) const OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync";
pub(crate) const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_compact_sync";
pub(crate) const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync";
pub(crate) const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync";
pub(crate) const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync";
pub(crate) const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync";
pub(crate) const EXECUTION_RUNTIME_SYNC_ACTION: &str = "execution_runtime_sync";
pub(crate) const EXECUTION_RUNTIME_SYNC_DECISION_ACTION: &str = "execution_runtime_sync_decision";
pub(crate) const EXECUTION_RUNTIME_STREAM_ACTION: &str = "execution_runtime_stream";
pub(crate) const EXECUTION_RUNTIME_STREAM_DECISION_ACTION: &str =
"execution_runtime_stream_decision";
pub(crate) fn parse_direct_request_body(
parts: &http::request::Parts,
body_bytes: &Bytes,
) -> Option<(serde_json::Value, Option<String>)> {
if is_json_request(&parts.headers) {
if body_bytes.is_empty() {
Some((serde_json::json!({}), None))
} else {
serde_json::from_slice::<serde_json::Value>(body_bytes)
.ok()
.map(|value| (value, None))
}
} else {
Some((
serde_json::json!({}),
(!body_bytes.is_empty())
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
))
}
}

View File

@@ -0,0 +1,162 @@
use std::collections::BTreeMap;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, ProxySnapshot};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::{AppState, GatewayControlAuthContext, GatewayControlDecision, GatewayError};
#[derive(Debug, Serialize)]
pub(crate) struct GatewayControlPlanRequest {
pub(crate) trace_id: String,
pub(crate) method: String,
pub(crate) path: String,
pub(crate) query_string: Option<String>,
pub(crate) headers: BTreeMap<String, String>,
pub(crate) body_json: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) body_base64: Option<String>,
pub(crate) auth_context: Option<GatewayControlAuthContext>,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct GatewayControlPlanResponse {
pub(crate) action: String,
#[serde(default)]
pub(crate) plan_kind: Option<String>,
#[serde(default)]
pub(crate) plan: Option<ExecutionPlan>,
#[serde(default)]
pub(crate) report_kind: Option<String>,
#[serde(default)]
pub(crate) report_context: Option<serde_json::Value>,
#[serde(default)]
pub(crate) auth_context: Option<GatewayControlAuthContext>,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct GatewayControlSyncDecisionResponse {
pub(crate) action: String,
#[serde(default)]
pub(crate) decision_kind: Option<String>,
#[serde(default)]
pub(crate) execution_strategy: Option<String>,
#[serde(default)]
pub(crate) conversion_mode: Option<String>,
#[serde(default)]
pub(crate) request_id: Option<String>,
#[serde(default)]
pub(crate) candidate_id: Option<String>,
#[serde(default)]
pub(crate) provider_name: Option<String>,
#[serde(default)]
pub(crate) provider_id: Option<String>,
#[serde(default)]
pub(crate) endpoint_id: Option<String>,
#[serde(default)]
pub(crate) key_id: Option<String>,
#[serde(default)]
pub(crate) upstream_base_url: Option<String>,
#[serde(default)]
pub(crate) upstream_url: Option<String>,
#[serde(default)]
pub(crate) provider_request_method: Option<String>,
#[serde(default)]
pub(crate) auth_header: Option<String>,
#[serde(default)]
pub(crate) auth_value: Option<String>,
#[serde(default)]
pub(crate) provider_api_format: Option<String>,
#[serde(default)]
pub(crate) client_api_format: Option<String>,
#[serde(default)]
pub(crate) provider_contract: Option<String>,
#[serde(default)]
pub(crate) client_contract: Option<String>,
#[serde(default)]
pub(crate) model_name: Option<String>,
#[serde(default)]
pub(crate) mapped_model: Option<String>,
#[serde(default)]
pub(crate) prompt_cache_key: Option<String>,
#[serde(default)]
pub(crate) extra_headers: BTreeMap<String, String>,
#[serde(default)]
pub(crate) provider_request_headers: BTreeMap<String, String>,
#[serde(default)]
pub(crate) provider_request_body: Option<serde_json::Value>,
#[serde(default)]
pub(crate) provider_request_body_base64: Option<String>,
#[serde(default)]
pub(crate) content_type: Option<String>,
#[serde(default)]
pub(crate) proxy: Option<ProxySnapshot>,
#[serde(default)]
pub(crate) tls_profile: Option<String>,
#[serde(default)]
pub(crate) timeouts: Option<ExecutionTimeouts>,
#[serde(default)]
pub(crate) upstream_is_stream: bool,
#[serde(default)]
pub(crate) report_kind: Option<String>,
#[serde(default)]
pub(crate) report_context: Option<serde_json::Value>,
#[serde(default)]
pub(crate) auth_context: Option<GatewayControlAuthContext>,
}
fn decision_has_exact_provider_request(payload: &GatewayControlSyncDecisionResponse) -> bool {
!payload.provider_request_headers.is_empty()
&& (payload.provider_request_body.is_some()
|| payload
.provider_request_body_base64
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false))
}
pub(crate) fn generic_decision_missing_exact_provider_request(
payload: &GatewayControlSyncDecisionResponse,
) -> bool {
if decision_has_exact_provider_request(payload) {
return false;
}
warn!(
decision_kind = payload.decision_kind.as_deref().unwrap_or_default(),
provider_api_format = payload.provider_api_format.as_deref().unwrap_or_default(),
client_api_format = payload.client_api_format.as_deref().unwrap_or_default(),
"gateway generic decision missing exact provider request; falling back to plan"
);
true
}
pub(crate) async fn build_gateway_plan_request(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: serde_json::Value,
body_base64: Option<String>,
) -> Result<GatewayControlPlanRequest, GatewayError> {
let auth_context = crate::gateway::resolve_execution_runtime_auth_context(
state,
decision,
&parts.headers,
&parts.uri,
trace_id,
)
.await?;
Ok(GatewayControlPlanRequest {
trace_id: trace_id.to_string(),
method: parts.method.to_string(),
path: parts.uri.path().to_string(),
query_string: parts.uri.query().map(ToOwned::to_owned),
headers: collect_control_headers(&parts.headers),
body_json,
body_base64,
auth_context,
})
}

View File

@@ -0,0 +1,175 @@
use crate::gateway::ai_pipeline::planner::common::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
build_openai_chat_stream_plan_from_decision, build_openai_chat_sync_plan_from_decision,
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::{
AppState, GatewayControlAuthContext, GatewayControlDecision, GatewayControlPlanResponse,
GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) async fn maybe_build_sync_plan_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let Some(plan_kind) = super::resolve_sync_plan_kind(parts, decision) else {
return Ok(None);
};
let Some(payload) = super::maybe_build_sync_decision_payload(
state,
parts,
trace_id,
decision,
body_json,
body_base64,
body_is_empty,
)
.await?
else {
return Ok(None);
};
build_sync_plan_payload_from_decision(parts, body_json, plan_kind, payload)
}
pub(crate) async fn maybe_build_stream_plan_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let Some(plan_kind) = super::resolve_stream_plan_kind(parts, decision) else {
return Ok(None);
};
let Some(payload) =
super::maybe_build_stream_decision_payload(state, parts, trace_id, decision, body_json)
.await?
else {
return Ok(None);
};
build_stream_plan_payload_from_decision(parts, body_json, plan_kind, payload)
}
fn build_sync_plan_payload_from_decision(
parts: &http::request::Parts,
body_json: &serde_json::Value,
plan_kind: &str,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let auth_context = payload.auth_context.clone();
let plan_and_report = match plan_kind {
OPENAI_CHAT_SYNC_PLAN_KIND => {
build_openai_chat_sync_plan_from_decision(parts, body_json, payload)?
}
OPENAI_CLI_SYNC_PLAN_KIND => {
build_openai_cli_sync_plan_from_decision(parts, body_json, payload, false)?
}
OPENAI_COMPACT_SYNC_PLAN_KIND => {
build_openai_cli_sync_plan_from_decision(parts, body_json, payload, true)?
}
CLAUDE_CHAT_SYNC_PLAN_KIND | CLAUDE_CLI_SYNC_PLAN_KIND => {
build_standard_sync_plan_from_decision(parts, body_json, payload)?
}
GEMINI_CHAT_SYNC_PLAN_KIND | GEMINI_CLI_SYNC_PLAN_KIND => {
build_gemini_sync_plan_from_decision(parts, body_json, payload)?
}
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
| GEMINI_FILES_LIST_PLAN_KIND
| GEMINI_FILES_GET_PLAN_KIND
| GEMINI_FILES_DELETE_PLAN_KIND => {
build_passthrough_sync_plan_from_decision(parts, payload)?
}
_ => None,
};
Ok(plan_and_report.map(|value| build_sync_plan_response(plan_kind, value, auth_context)))
}
fn build_stream_plan_payload_from_decision(
parts: &http::request::Parts,
body_json: &serde_json::Value,
plan_kind: &str,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
let auth_context = payload.auth_context.clone();
let plan_and_report = match plan_kind {
OPENAI_CHAT_STREAM_PLAN_KIND => {
build_openai_chat_stream_plan_from_decision(parts, body_json, payload)?
}
OPENAI_CLI_STREAM_PLAN_KIND => {
build_openai_cli_stream_plan_from_decision(parts, body_json, payload, false)?
}
OPENAI_COMPACT_STREAM_PLAN_KIND => {
build_openai_cli_stream_plan_from_decision(parts, body_json, payload, true)?
}
CLAUDE_CHAT_STREAM_PLAN_KIND | CLAUDE_CLI_STREAM_PLAN_KIND => {
build_standard_stream_plan_from_decision(parts, body_json, payload, true)?
}
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
build_gemini_stream_plan_from_decision(parts, body_json, payload)?
}
OPENAI_VIDEO_CONTENT_PLAN_KIND | GEMINI_FILES_DOWNLOAD_PLAN_KIND => {
build_passthrough_stream_plan_from_decision(parts, payload)?
}
_ => None,
};
Ok(plan_and_report.map(|value| build_stream_plan_response(plan_kind, value, auth_context)))
}
fn build_sync_plan_response(
plan_kind: &str,
value: LocalSyncPlanAndReport,
auth_context: Option<GatewayControlAuthContext>,
) -> GatewayControlPlanResponse {
GatewayControlPlanResponse {
action: EXECUTION_RUNTIME_SYNC_ACTION.to_string(),
plan_kind: Some(plan_kind.to_string()),
plan: Some(value.plan),
report_kind: value.report_kind,
report_context: value.report_context,
auth_context,
}
}
fn build_stream_plan_response(
plan_kind: &str,
value: LocalStreamPlanAndReport,
auth_context: Option<GatewayControlAuthContext>,
) -> GatewayControlPlanResponse {
GatewayControlPlanResponse {
action: EXECUTION_RUNTIME_STREAM_ACTION.to_string(),
plan_kind: Some(plan_kind.to_string()),
plan: Some(value.plan),
report_kind: value.report_kind,
report_context: value.report_context,
auth_context,
}
}

View File

@@ -0,0 +1,22 @@
mod control_plan;
mod stream;
mod sync;
pub(crate) use self::control_plan::{
maybe_build_stream_plan_payload_impl, maybe_build_sync_plan_payload_impl,
};
pub(crate) use self::stream::maybe_build_stream_decision_payload_impl as maybe_build_stream_decision_payload;
pub(crate) use self::sync::maybe_build_sync_decision_payload_impl as maybe_build_sync_decision_payload;
pub(crate) use super::{
maybe_build_stream_local_decision_payload,
maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_stream_local_standard_decision_payload, maybe_build_sync_local_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_build_sync_local_standard_decision_payload,
maybe_build_sync_local_video_decision_payload, resolve_stream_plan_kind,
resolve_sync_plan_kind,
};

View File

@@ -0,0 +1,156 @@
use std::collections::BTreeMap;
use crate::gateway::ai_pipeline::planner::common::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, OPENAI_VIDEO_CONTENT_PLAN_KIND,
};
use crate::gateway::scheduler::{
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
};
use crate::gateway::{
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) async fn maybe_build_stream_decision_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) else {
return Ok(None);
};
if !is_matching_stream_request(plan_kind, parts, body_json) {
return Ok(None);
}
if let Some(payload) = maybe_build_local_video_task_content_stream_decision_payload(
state, parts, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_openai_cli_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_standard_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_same_format_provider_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_stream_local_gemini_files_decision_payload(
state, parts, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
Ok(None)
}
async fn maybe_build_local_video_task_content_stream_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if plan_kind != OPENAI_VIDEO_CONTENT_PLAN_KIND
|| decision.route_family.as_deref() != Some("openai")
{
return Ok(None);
}
let _ = state
.hydrate_video_task_for_route(decision.route_family.as_deref(), parts.uri.path())
.await?;
let Some(action) = state.video_tasks.prepare_openai_content_stream_action(
parts.uri.path(),
parts.uri.query(),
trace_id,
) else {
return Ok(None);
};
let crate::gateway::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) = action else {
return Ok(None);
};
let provider_contract = plan.provider_api_format.clone();
let client_contract = plan.client_api_format.clone();
let execution_strategy = if plan.provider_api_format == plan.client_api_format {
crate::gateway::ExecutionStrategy::LocalSameFormat
} else {
crate::gateway::ExecutionStrategy::LocalCrossFormat
};
let conversion_mode = if plan.provider_api_format == plan.client_api_format {
crate::gateway::ConversionMode::None
} else {
crate::gateway::ConversionMode::Bidirectional
};
Ok(Some(GatewayControlSyncDecisionResponse {
action: EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string(),
decision_kind: Some(plan_kind.to_string()),
execution_strategy: Some(execution_strategy.as_str().to_string()),
conversion_mode: Some(conversion_mode.as_str().to_string()),
request_id: Some(plan.request_id),
candidate_id: plan.candidate_id,
provider_name: plan.provider_name,
provider_id: Some(plan.provider_id),
endpoint_id: Some(plan.endpoint_id),
key_id: Some(plan.key_id),
upstream_base_url: None,
upstream_url: Some(plan.url),
provider_request_method: Some(plan.method),
auth_header: None,
auth_value: None,
provider_api_format: Some(plan.provider_api_format),
client_api_format: Some(plan.client_api_format),
provider_contract: Some(provider_contract),
client_contract: Some(client_contract),
model_name: plan.model_name,
mapped_model: None,
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: plan.headers,
provider_request_body: None,
provider_request_body_base64: None,
content_type: plan.content_type,
proxy: plan.proxy,
tls_profile: plan.tls_profile,
timeouts: plan.timeouts,
upstream_is_stream: true,
report_kind: None,
report_context: None,
auth_context: decision.auth_context.clone(),
}))
}

View File

@@ -0,0 +1,222 @@
use std::collections::BTreeMap;
use url::Url;
use crate::gateway::ai_pipeline::planner::common::{
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_FILES_DELETE_PLAN_KIND,
GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
use crate::gateway::scheduler::resolve_execution_runtime_sync_plan_kind;
use crate::gateway::{
resolve_execution_runtime_auth_context, AppState, GatewayControlDecision,
GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) async fn maybe_build_sync_decision_payload_impl(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(plan_kind) = resolve_execution_runtime_sync_plan_kind(parts, decision) else {
return Ok(None);
};
if let Some(payload) = maybe_build_local_video_task_follow_up_sync_decision_payload(
state, parts, body_json, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_video_decision_payload(
state, parts, body_json, trace_id, decision, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_openai_cli_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_standard_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if let Some(payload) = super::maybe_build_sync_local_same_format_provider_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
if matches!(
plan_kind,
GEMINI_FILES_LIST_PLAN_KIND | GEMINI_FILES_GET_PLAN_KIND | GEMINI_FILES_DELETE_PLAN_KIND
) {
if let Some(payload) = super::maybe_build_sync_local_gemini_files_decision_payload(
state,
parts,
body_json,
body_base64,
body_is_empty,
trace_id,
decision,
plan_kind,
)
.await?
{
return Ok(Some(payload));
}
}
Ok(None)
}
async fn maybe_build_local_video_task_follow_up_sync_decision_payload(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if !matches!(
plan_kind,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
) {
return Ok(None);
}
let _ = state
.hydrate_video_task_for_route(decision.route_family.as_deref(), parts.uri.path())
.await?;
let auth_context = resolve_execution_runtime_auth_context(
state,
decision,
&parts.headers,
&parts.uri,
trace_id,
)
.await?;
let Some(auth_context) = auth_context else {
return Ok(None);
};
let Some(follow_up) = state.video_tasks.prepare_follow_up_sync_plan(
plan_kind,
parts.uri.path(),
Some(body_json),
Some(&auth_context),
trace_id,
) else {
return Ok(None);
};
let auth_pair = extract_auth_header_pair(&follow_up.plan.headers);
let execution_strategy =
if follow_up.plan.provider_api_format == follow_up.plan.client_api_format {
crate::gateway::ExecutionStrategy::LocalSameFormat
} else {
crate::gateway::ExecutionStrategy::LocalCrossFormat
};
let conversion_mode = if follow_up.plan.provider_api_format == follow_up.plan.client_api_format
{
crate::gateway::ConversionMode::None
} else {
crate::gateway::ConversionMode::Bidirectional
};
Ok(Some(GatewayControlSyncDecisionResponse {
action: EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string(),
decision_kind: Some(plan_kind.to_string()),
execution_strategy: Some(execution_strategy.as_str().to_string()),
conversion_mode: Some(conversion_mode.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: follow_up.plan.candidate_id.clone(),
provider_name: follow_up.plan.provider_name.clone(),
provider_id: Some(follow_up.plan.provider_id.clone()),
endpoint_id: Some(follow_up.plan.endpoint_id.clone()),
key_id: Some(follow_up.plan.key_id.clone()),
upstream_base_url: infer_upstream_base_url(&follow_up.plan.url),
upstream_url: Some(follow_up.plan.url.clone()),
provider_request_method: Some(follow_up.plan.method.clone()),
auth_header: auth_pair.as_ref().map(|(name, _)| name.clone()),
auth_value: auth_pair.as_ref().map(|(_, value)| value.clone()),
provider_api_format: Some(follow_up.plan.provider_api_format.clone()),
client_api_format: Some(follow_up.plan.client_api_format.clone()),
provider_contract: Some(follow_up.plan.provider_api_format.clone()),
client_contract: Some(follow_up.plan.client_api_format.clone()),
model_name: follow_up.plan.model_name.clone(),
mapped_model: None,
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: follow_up.plan.headers.clone(),
provider_request_body: follow_up.plan.body.json_body.clone(),
provider_request_body_base64: follow_up.plan.body.body_bytes_b64.clone(),
content_type: follow_up.plan.content_type.clone(),
proxy: follow_up.plan.proxy.clone(),
tls_profile: follow_up.plan.tls_profile.clone(),
timeouts: follow_up.plan.timeouts.clone(),
upstream_is_stream: false,
report_kind: follow_up.report_kind,
report_context: follow_up.report_context,
auth_context: Some(auth_context),
}))
}
fn extract_auth_header_pair(headers: &BTreeMap<String, String>) -> Option<(String, String)> {
[
"authorization",
"x-api-key",
"api-key",
"x-goog-api-key",
"proxy-authorization",
]
.into_iter()
.find_map(|name| {
headers
.iter()
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
.map(|(header_name, value)| (header_name.clone(), value.clone()))
})
}
fn infer_upstream_base_url(upstream_url: &str) -> Option<String> {
let parsed = Url::parse(upstream_url).ok()?;
let host = parsed.host_str()?;
let mut base = format!("{}://{}", parsed.scheme(), host);
if let Some(port) = parsed.port() {
base.push(':');
base.push_str(port.to_string().as_str());
}
Some(base)
}

View File

@@ -0,0 +1,132 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::request_candidates::record_local_request_candidate_status;
use crate::gateway::{
execute_execution_runtime_stream, execute_execution_runtime_sync, AppState,
GatewayControlDecision, GatewayError,
};
pub(crate) trait LocalPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan;
fn report_kind(&self) -> Option<String>;
fn report_context(&self) -> Option<serde_json::Value>;
}
impl LocalPlanAndReport for LocalSyncPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_kind(&self) -> Option<String> {
self.report_kind.clone()
}
fn report_context(&self) -> Option<serde_json::Value> {
self.report_context.clone()
}
}
impl LocalPlanAndReport for LocalStreamPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_kind(&self) -> Option<String> {
self.report_kind.clone()
}
fn report_context(&self) -> Option<serde_json::Value> {
self.report_context.clone()
}
}
pub(crate) async fn execute_sync_plan_and_reports<T>(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
plan_and_reports: Vec<T>,
) -> Result<Option<Response<Body>>, GatewayError>
where
T: LocalPlanAndReport,
{
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_sync(
state,
parts.uri.path(),
plan_and_report.plan().clone(),
trace_id,
decision,
plan_kind,
plan_and_report.report_kind(),
plan_and_report.report_context(),
)
.await?
{
mark_unused_local_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn execute_stream_plan_and_reports<T>(
state: &AppState,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
plan_and_reports: Vec<T>,
) -> Result<Option<Response<Body>>, GatewayError>
where
T: LocalPlanAndReport,
{
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_stream(
state,
plan_and_report.plan().clone(),
trace_id,
decision,
plan_kind,
plan_and_report.report_kind(),
plan_and_report.report_context(),
)
.await?
{
mark_unused_local_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn mark_unused_local_candidates<T>(state: &AppState, remaining: Vec<T>)
where
T: LocalPlanAndReport,
{
for plan_and_report in remaining {
record_local_request_candidate_status(
state,
plan_and_report.plan(),
plan_and_report.report_context().as_ref(),
aether_data::repository::candidates::RequestCandidateStatus::Unused,
None,
None,
None,
None,
None,
None,
)
.await;
}
}

View File

@@ -0,0 +1,25 @@
use axum::body::{Body, Bytes};
use axum::http::Response;
use crate::gateway::intent;
use crate::gateway::{AppState, GatewayControlDecision, GatewayError};
pub(crate) async fn maybe_execute_sync_local_path(
state: &AppState,
parts: &http::request::Parts,
body_bytes: &Bytes,
trace_id: &str,
decision: &GatewayControlDecision,
) -> Result<Option<Response<Body>>, GatewayError> {
intent::maybe_execute_via_sync_intent_path(state, parts, body_bytes, trace_id, decision).await
}
pub(crate) async fn maybe_execute_stream_local_path(
state: &AppState,
parts: &http::request::Parts,
body_bytes: &Bytes,
trace_id: &str,
decision: &GatewayControlDecision,
) -> Result<Option<Response<Body>>, GatewayError> {
intent::maybe_execute_via_stream_intent_path(state, parts, body_bytes, trace_id, decision).await
}

View File

@@ -0,0 +1,134 @@
use crate::gateway::{AppState, GatewayControlDecision, GatewayError};
pub(crate) mod candidate_affinity;
pub(crate) mod common;
pub(crate) mod contracts;
mod decision;
pub(crate) mod family_core;
pub(crate) mod local_path;
pub(crate) mod passthrough;
pub(crate) mod plan_builders;
pub(crate) mod specialized;
pub(crate) mod standard;
pub(crate) use self::candidate_affinity::prefer_local_tunnel_owner_candidates;
pub(crate) use self::common::{
parse_direct_request_body, CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
pub(crate) use self::contracts::{
build_gateway_plan_request, generic_decision_missing_exact_provider_request,
GatewayControlPlanRequest, GatewayControlPlanResponse, GatewayControlSyncDecisionResponse,
};
pub(crate) use crate::gateway::ai_pipeline::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub(crate) use crate::gateway::scheduler::{
is_matching_stream_request,
resolve_execution_runtime_stream_plan_kind as resolve_stream_plan_kind,
resolve_execution_runtime_sync_plan_kind as resolve_sync_plan_kind,
};
pub(crate) use passthrough::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
pub(crate) use specialized::{
maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
maybe_build_sync_local_video_decision_payload,
maybe_execute_stream_via_local_gemini_files_decision,
maybe_execute_sync_via_local_gemini_files_decision,
maybe_execute_sync_via_local_video_decision,
};
pub(crate) use local_path::{maybe_execute_stream_local_path, maybe_execute_sync_local_path};
pub(crate) use standard::{
copy_request_number_field, copy_request_number_field_as,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
maybe_build_stream_local_decision_payload,
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_stream_local_standard_decision_payload, maybe_build_sync_local_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
maybe_build_sync_local_standard_decision_payload, maybe_execute_stream_via_local_decision,
maybe_execute_stream_via_local_openai_cli_decision,
maybe_execute_stream_via_local_standard_decision, maybe_execute_sync_via_local_decision,
maybe_execute_sync_via_local_openai_cli_decision,
maybe_execute_sync_via_local_standard_decision, parse_openai_stop_sequences,
resolve_openai_chat_max_tokens, value_as_u64,
};
pub(crate) async fn maybe_build_sync_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
decision::maybe_build_sync_decision_payload(
state,
parts,
trace_id,
decision,
body_json,
body_base64,
body_is_empty,
)
.await
}
pub(crate) async fn maybe_build_stream_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
decision::maybe_build_stream_decision_payload(state, parts, trace_id, decision, body_json).await
}
pub(crate) async fn maybe_build_sync_plan_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
decision::maybe_build_sync_plan_payload_impl(
state,
parts,
trace_id,
decision,
body_json,
body_base64,
body_is_empty,
)
.await
}
pub(crate) async fn maybe_build_stream_plan_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Result<Option<GatewayControlPlanResponse>, GatewayError> {
decision::maybe_build_stream_plan_payload_impl(state, parts, trace_id, decision, body_json)
.await
}

View File

@@ -0,0 +1,11 @@
//! Requests that can stay in the same public/provider contract family.
mod provider;
pub(crate) use self::provider::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
pub(crate) use crate::gateway::provider_transport::provider_type_supports_local_same_format_transport;

View File

@@ -0,0 +1,233 @@
use aether_contracts::{ExecutionPlan, RequestBody};
use super::{augment_sync_report_context, LocalStreamPlanAndReport, LocalSyncPlanAndReport};
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) fn build_passthrough_sync_plan_from_decision(
parts: &http::request::Parts,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalSyncPlanAndReport>, GatewayError> {
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let (request_body, provider_request_body_for_report) = resolve_passthrough_sync_request_body(
payload.provider_request_body.clone(),
payload.provider_request_body_base64.clone(),
);
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: payload
.provider_request_method
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| parts.method.to_string()),
url: upstream_url,
headers: payload.provider_request_headers.clone(),
content_type: payload.content_type.clone().or_else(|| {
payload
.provider_request_headers
.get("content-type")
.cloned()
}),
content_encoding: None,
body: request_body,
stream: false,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_for_report,
)?;
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_passthrough_stream_plan_from_decision(
parts: &http::request::Parts,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalStreamPlanAndReport>, GatewayError> {
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: parts.method.to_string(),
url: upstream_url,
headers: payload.provider_request_headers.clone(),
content_type: payload.content_type.clone().or_else(|| {
payload
.provider_request_headers
.get("content-type")
.cloned()
}),
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
stream: true,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context: payload.report_context,
}))
}
fn resolve_passthrough_sync_request_body(
provider_request_body: Option<serde_json::Value>,
provider_request_body_base64: Option<String>,
) -> (RequestBody, serde_json::Value) {
if let Some(body_bytes_b64) = provider_request_body_base64
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
{
return (
RequestBody {
json_body: None,
body_bytes_b64: Some(body_bytes_b64.clone()),
body_ref: None,
},
serde_json::json!({"body_bytes_b64": body_bytes_b64}),
);
}
match provider_request_body.unwrap_or(serde_json::Value::Null) {
serde_json::Value::Null => (
RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
serde_json::Value::Null,
),
other => {
let report_body = other.clone();
(RequestBody::from_json(other), report_body)
}
}
}

View File

@@ -0,0 +1,76 @@
use axum::body::Body;
use axum::http::Response;
use std::collections::BTreeMap;
use url::form_urlencoded;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::{json, Value};
use tracing::warn;
use uuid::Uuid;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_body_rules, apply_local_header_rules, build_antigravity_safe_v1internal_request,
build_antigravity_static_identity_headers, build_antigravity_v1internal_url,
build_claude_code_messages_url, build_claude_code_passthrough_headers,
build_claude_messages_url, build_gemini_content_url,
build_kiro_generate_assistant_response_url, build_kiro_provider_headers,
build_kiro_provider_request_body, build_openai_passthrough_headers, build_passthrough_headers,
build_passthrough_path_url, build_vertex_api_key_gemini_content_url,
classify_local_antigravity_request_support, ensure_upstream_auth_header,
resolve_local_gemini_auth, resolve_local_standard_auth,
resolve_local_vertex_api_key_query_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
sanitize_claude_code_request_body, supports_local_claude_code_transport_with_network,
supports_local_gemini_transport_with_network,
supports_local_kiro_request_transport_with_network,
supports_local_standard_transport_with_network,
supports_local_vertex_api_key_gemini_transport_with_network, AntigravityEnvelopeRequestType,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport, AntigravityRequestUrlAction,
LocalResolvedOAuthRequestAuth, KIRO_ENVELOPE_NAME,
};
use crate::gateway::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::{
append_execution_contract_fields_to_value, execute_execution_runtime_stream,
execute_execution_runtime_sync, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
mod family;
mod plans;
mod request;
pub(super) use self::family::{
materialize_local_same_format_provider_candidate_attempts,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
resolve_local_same_format_provider_decision_input, LocalSameFormatProviderFamily,
LocalSameFormatProviderSpec,
};
pub(crate) use self::family::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
use self::plans::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports, resolve_stream_spec,
resolve_sync_spec,
};
use self::request::{
build_same_format_provider_request_body, build_same_format_upstream_url,
extract_gemini_model_from_path,
};
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";

View File

@@ -0,0 +1,163 @@
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use uuid::Uuid;
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::scheduler::list_selectable_candidates;
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayError,
};
use super::types::{
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
};
pub(crate) async fn resolve_local_same_format_provider_decision_input(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Option<LocalSameFormatProviderDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
return None;
};
let requested_model = match spec.family {
LocalSameFormatProviderFamily::Standard => body_json
.get("model")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)?,
LocalSameFormatProviderFamily::Gemini => {
super::super::request::extract_gemini_model_from_path(parts.uri.path())?
}
};
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
current_unix_secs(),
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => return None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format decision auth snapshot read failed"
);
return None;
}
};
Some(LocalSameFormatProviderDecisionInput {
auth_context,
requested_model,
auth_snapshot,
})
}
pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalSameFormatProviderDecisionInput,
spec: LocalSameFormatProviderSpec,
) -> Result<Vec<LocalSameFormatProviderCandidateAttempt>, GatewayError> {
let candidates = list_selectable_candidates(
state,
spec.api_format,
&input.requested_model,
spec.require_streaming,
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await?;
let candidates =
crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates(state, candidates)
.await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let generated_candidate_id = Uuid::new_v4().to_string();
let extra_data = append_execution_contract_fields_to_value(
json!({
"provider_api_format": spec.api_format,
"client_api_format": spec.api_format,
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
}),
ExecutionStrategy::LocalSameFormat,
ConversionMode::None,
spec.api_format,
spec.api_format,
);
let candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: generated_candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => generated_candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format decision request candidate upsert failed"
);
generated_candidate_id.clone()
}
};
attempts.push(LocalSameFormatProviderCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id,
});
}
Ok(attempts)
}

View File

@@ -0,0 +1,141 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::ai_pipeline::planner::family_core::{
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::{AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError};
use super::super::plans::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports, resolve_stream_spec,
resolve_sync_spec,
};
use super::candidates::{
materialize_local_same_format_provider_candidate_attempts,
resolve_local_same_format_provider_decision_input,
};
use super::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
pub(crate) async fn maybe_execute_sync_via_local_same_format_provider_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_sync_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_sync_plan_and_reports(state, parts, trace_id, decision, plan_kind, plan_and_reports)
.await
}
pub(crate) async fn maybe_execute_stream_via_local_same_format_provider_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_stream_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
}
pub(crate) async fn maybe_build_sync_local_same_format_provider_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
for attempt in attempts {
if let Some(payload) =
maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_stream_local_same_format_provider_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
for attempt in attempts {
if let Some(payload) =
maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}

View File

@@ -0,0 +1,17 @@
mod candidates;
mod execute;
mod payload;
mod types;
pub(crate) use self::candidates::{
materialize_local_same_format_provider_candidate_attempts,
resolve_local_same_format_provider_decision_input,
};
pub(crate) use self::execute::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
maybe_execute_stream_via_local_same_format_provider_decision,
maybe_execute_sync_via_local_same_format_provider_decision,
};
pub(crate) use self::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
pub(crate) use self::types::{LocalSameFormatProviderFamily, LocalSameFormatProviderSpec};

View File

@@ -0,0 +1,562 @@
use std::collections::BTreeMap;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_header_rules, build_antigravity_safe_v1internal_request,
build_antigravity_static_identity_headers, build_claude_code_passthrough_headers,
build_openai_passthrough_headers, build_passthrough_headers,
classify_local_antigravity_request_support, ensure_upstream_auth_header,
resolve_local_gemini_auth, resolve_local_standard_auth,
resolve_local_vertex_api_key_query_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
supports_local_claude_code_transport_with_network,
supports_local_gemini_transport_with_network,
supports_local_kiro_request_transport_with_network,
supports_local_standard_transport_with_network,
supports_local_vertex_api_key_gemini_transport_with_network, AntigravityEnvelopeRequestType,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
LocalResolvedOAuthRequestAuth, KIRO_ENVELOPE_NAME,
};
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate;
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlSyncDecisionResponse, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
use super::types::{
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
};
pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalSameFormatProviderDecisionInput,
attempt: LocalSameFormatProviderCandidateAttempt,
spec: LocalSameFormatProviderSpec,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalSameFormatProviderCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format decision provider transport read failed"
);
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
let is_antigravity = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("antigravity");
let is_claude_code = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("claude_code");
let is_vertex = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("vertex_ai");
let transport_supported = match spec.family {
_ if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro") =>
{
supports_local_kiro_request_transport_with_network(&transport)
}
_ if is_antigravity => true,
_ if is_claude_code => {
supports_local_claude_code_transport_with_network(&transport, spec.api_format)
}
_ if is_vertex => supports_local_vertex_api_key_gemini_transport_with_network(&transport),
LocalSameFormatProviderFamily::Standard => {
supports_local_standard_transport_with_network(&transport, spec.api_format)
}
LocalSameFormatProviderFamily::Gemini => {
supports_local_gemini_transport_with_network(&transport, spec.api_format)
}
};
if !transport_supported {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let is_kiro = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro");
let vertex_query_auth = if is_vertex {
resolve_local_vertex_api_key_query_auth(&transport)
} else {
None
};
let should_try_oauth_auth = is_kiro
|| matches!(spec.family, LocalSameFormatProviderFamily::Standard)
&& resolve_local_standard_auth(&transport).is_none()
|| matches!(spec.family, LocalSameFormatProviderFamily::Gemini)
&& !is_vertex
&& resolve_local_gemini_auth(&transport).is_none();
let oauth_auth = if should_try_oauth_auth {
match state.resolve_local_oauth_request_auth(&transport).await {
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(auth))) => {
Some(LocalResolvedOAuthRequestAuth::Kiro(auth))
}
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => {
Some(LocalResolvedOAuthRequestAuth::Header { name, value })
}
Ok(None) => None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
provider_type = %transport.provider.provider_type,
error = ?err,
"gateway local same-format oauth auth resolution failed"
);
None
}
}
} else {
None
};
let kiro_auth = match oauth_auth.as_ref() {
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
_ => None,
};
let auth = if let Some(auth) = kiro_auth.as_ref() {
Some((auth.name.to_string(), auth.value.clone()))
} else if let Some(LocalResolvedOAuthRequestAuth::Header { name, value }) = oauth_auth.as_ref()
{
Some((name.clone(), value.clone()))
} else if is_vertex {
None
} else {
match spec.family {
LocalSameFormatProviderFamily::Standard => resolve_local_standard_auth(&transport),
LocalSameFormatProviderFamily::Gemini => resolve_local_gemini_auth(&transport),
}
};
let (auth_header, auth_value) = match auth {
Some((name, value)) => (Some(name), Some(value)),
None if is_vertex && vertex_query_auth.is_some() => (None, None),
None => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
}
};
if is_vertex && vertex_query_auth.is_none() {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
}
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let Some(base_provider_request_body) =
super::super::request::build_same_format_provider_request_body(
body_json,
&mapped_model,
spec,
transport.endpoint.body_rules.as_ref(),
is_kiro || is_antigravity || spec.require_streaming,
kiro_auth,
is_claude_code,
)
else {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
};
let antigravity_auth = if is_antigravity {
match classify_local_antigravity_request_support(
&transport,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestSideSupport::Supported(spec) => Some(spec.auth),
AntigravityRequestSideSupport::Unsupported(_) => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
}
} else {
None
};
let provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
match build_antigravity_safe_v1internal_request(
antigravity_auth,
trace_id,
&mapped_model,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
}
} else {
base_provider_request_body
};
let upstream_is_stream = is_kiro || is_antigravity || spec.require_streaming;
let report_kind = if is_kiro && !spec.require_streaming {
"claude_cli_sync_finalize"
} else if is_antigravity && !spec.require_streaming {
match spec.api_format {
"gemini:chat" => "gemini_chat_sync_finalize",
"gemini:cli" => "gemini_cli_sync_finalize",
_ => spec.report_kind,
}
} else {
spec.report_kind
};
let Some(upstream_url) = super::super::request::build_same_format_upstream_url(
parts,
&transport,
&mapped_model,
spec,
upstream_is_stream,
kiro_auth,
) else {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"upstream_url_missing",
)
.await;
return None;
};
let Some(provider_request_headers) = (if let Some(kiro_auth) = kiro_auth.as_ref() {
crate::gateway::provider_transport::build_kiro_provider_headers(
&parts.headers,
&provider_request_body,
body_json,
transport.endpoint.header_rules.as_ref(),
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&kiro_auth.auth_config,
kiro_auth.machine_id.as_str(),
)
} else {
let extra_headers = antigravity_auth
.as_ref()
.map(build_antigravity_static_identity_headers)
.unwrap_or_default();
let mut provider_request_headers = if is_claude_code {
build_claude_code_passthrough_headers(
&parts.headers,
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&extra_headers,
upstream_is_stream,
transport.key.fingerprint.as_ref(),
)
} else if is_vertex {
build_passthrough_headers(&parts.headers, &extra_headers, Some("application/json"))
} else {
build_openai_passthrough_headers(
&parts.headers,
auth_header.as_deref().unwrap_or_default(),
auth_value.as_deref().unwrap_or_default(),
&extra_headers,
Some("application/json"),
)
};
let protected_headers = auth_header
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| vec![value, "content-type"])
.unwrap_or_else(|| vec!["content-type"]);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&protected_headers,
&provider_request_body,
Some(body_json),
) {
None
} else {
if let (Some(auth_header), Some(auth_value)) =
(auth_header.as_deref(), auth_value.as_deref())
{
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if upstream_is_stream {
provider_request_headers
.insert("accept".to_string(), "text/event-stream".to_string());
}
Some(provider_request_headers)
}
}) else {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
};
let prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await;
let tls_profile = resolve_transport_tls_profile(&transport);
let report_context = append_execution_contract_fields_to_value(
json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model,
"provider_name": transport.provider.name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"provider_api_format": spec.api_format,
"client_api_format": spec.api_format,
"mapped_model": mapped_model,
"upstream_url": upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": provider_request_headers,
"provider_request_body": provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": is_kiro || is_antigravity,
"envelope_name": if is_kiro {
Some(KIRO_ENVELOPE_NAME)
} else if is_antigravity {
Some(super::super::ANTIGRAVITY_ENVELOPE_NAME)
} else {
None
},
"needs_conversion": false,
}),
ExecutionStrategy::LocalSameFormat,
ConversionMode::None,
spec.api_format,
spec.api_format,
);
Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(ExecutionStrategy::LocalSameFormat.as_str().to_string()),
conversion_mode: Some(ConversionMode::None.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.clone()),
provider_name: Some(transport.provider.name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url.clone()),
provider_request_method: None,
auth_header,
auth_value,
provider_api_format: Some(spec.api_format.to_string()),
client_api_format: Some(spec.api_format.to_string()),
provider_contract: Some(spec.api_format.to_string()),
client_contract: Some(spec.api_format.to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key,
extra_headers: BTreeMap::new(),
provider_request_headers: provider_request_headers.clone(),
provider_request_body: Some(provider_request_body.clone()),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream,
report_kind: Some(report_kind.to_string()),
report_context: Some(report_context),
auth_context: Some(input.auth_context.clone()),
})
}
pub(super) async fn mark_skipped_local_same_format_provider_candidate(
state: &AppState,
input: &LocalSameFormatProviderDecisionInput,
trace_id: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(current_unix_secs()),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local same-format decision failed to persist skipped candidate"
);
}
}

View File

@@ -0,0 +1,28 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalSameFormatProviderFamily {
Standard,
Gemini,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LocalSameFormatProviderSpec {
pub(crate) api_format: &'static str,
pub(crate) decision_kind: &'static str,
pub(crate) report_kind: &'static str,
pub(crate) family: LocalSameFormatProviderFamily,
pub(crate) require_streaming: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct LocalSameFormatProviderDecisionInput {
pub(crate) auth_context: crate::gateway::GatewayControlAuthContext,
pub(crate) requested_model: String,
pub(crate) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
pub(crate) struct LocalSameFormatProviderCandidateAttempt {
pub(crate) candidate: crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate,
pub(crate) candidate_index: u32,
pub(crate) candidate_id: String,
}

View File

@@ -0,0 +1,198 @@
use tracing::warn;
use super::{
materialize_local_same_format_provider_candidate_attempts,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
resolve_local_same_format_provider_decision_input, AppState, GatewayControlDecision,
GatewayError, LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
};
use crate::gateway::ai_pipeline::planner::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
match plan_kind {
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
report_kind: "claude_chat_sync_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: false,
}),
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
report_kind: "claude_cli_sync_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: false,
}),
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
report_kind: "gemini_chat_sync_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: false,
}),
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
report_kind: "gemini_cli_sync_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
match plan_kind {
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
report_kind: "claude_chat_stream_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: true,
}),
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
report_kind: "claude_cli_stream_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: true,
}),
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
report_kind: "gemini_chat_stream_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: true,
}),
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
report_kind: "gemini_cli_stream_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: true,
}),
_ => None,
}
}
pub(super) async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalSameFormatProviderFamily::Standard => {
build_standard_sync_plan_from_decision(parts, body_json, payload)
}
LocalSameFormatProviderFamily::Gemini => {
build_gemini_sync_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format sync decision plan build failed"
);
}
}
}
Ok(plans)
}
pub(super) async fn build_local_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalSameFormatProviderFamily::Standard => {
build_standard_stream_plan_from_decision(parts, body_json, payload, false)
}
LocalSameFormatProviderFamily::Gemini => {
build_gemini_stream_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local same-format stream decision plan build failed"
);
}
}
}
Ok(plans)
}

View File

@@ -0,0 +1,186 @@
use super::*;
pub(super) fn build_same_format_provider_request_body(
body_json: &Value,
mapped_model: &str,
spec: LocalSameFormatProviderSpec,
body_rules: Option<&Value>,
upstream_is_stream: bool,
kiro_auth: Option<&crate::gateway::provider_transport::KiroRequestAuth>,
is_claude_code: bool,
) -> Option<Value> {
if let Some(kiro_auth) = kiro_auth {
return build_kiro_provider_request_body(
body_json,
mapped_model,
&kiro_auth.auth_config,
body_rules,
);
}
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())),
);
match spec.family {
LocalSameFormatProviderFamily::Standard => {
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));
}
}
LocalSameFormatProviderFamily::Gemini => {
provider_request_body.remove("model");
}
}
let mut provider_request_body = Value::Object(provider_request_body);
if is_claude_code {
sanitize_claude_code_request_body(&mut provider_request_body);
}
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
pub(super) fn build_same_format_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str,
spec: LocalSameFormatProviderSpec,
upstream_is_stream: bool,
kiro_auth: Option<&crate::gateway::provider_transport::KiroRequestAuth>,
) -> Option<String> {
if let Some(kiro_auth) = kiro_auth {
return build_kiro_generate_assistant_response_url(
&transport.endpoint.base_url,
parts.uri.query(),
Some(kiro_auth.auth_config.effective_api_region()),
);
}
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("claude_code")
{
return Some(build_claude_code_messages_url(
&transport.endpoint.base_url,
parts.uri.query(),
));
}
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("vertex_ai")
{
let auth = resolve_local_vertex_api_key_query_auth(transport)?;
return build_vertex_api_key_gemini_content_url(
mapped_model,
upstream_is_stream,
&auth.value,
parts.uri.query(),
);
}
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("antigravity")
{
let query = parts.uri.query().map(|query| {
form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect::<BTreeMap<String, String>>()
});
return build_antigravity_v1internal_url(
&transport.endpoint.base_url,
if upstream_is_stream {
AntigravityRequestUrlAction::StreamGenerateContent
} else {
AntigravityRequestUrlAction::GenerateContent
},
query.as_ref(),
);
}
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(path) = custom_path {
let blocked_keys = match spec.family {
LocalSameFormatProviderFamily::Standard => &[][..],
LocalSameFormatProviderFamily::Gemini => &["key"][..],
};
let url = build_passthrough_path_url(
&transport.endpoint.base_url,
path,
parts.uri.query(),
blocked_keys,
)?;
return Some(maybe_add_gemini_stream_alt_sse(url, spec));
}
let url = match spec.family {
LocalSameFormatProviderFamily::Standard => Some(build_claude_messages_url(
&transport.endpoint.base_url,
parts.uri.query(),
)),
LocalSameFormatProviderFamily::Gemini => build_gemini_content_url(
&transport.endpoint.base_url,
mapped_model,
spec.require_streaming,
parts.uri.query(),
),
}?;
Some(maybe_add_gemini_stream_alt_sse(url, spec))
}
pub(super) fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let (_, suffix) = path.split_once("/models/")?;
let model = suffix
.split_once(':')
.map(|(value, _)| value)
.unwrap_or(suffix);
let model = model.trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}
fn maybe_add_gemini_stream_alt_sse(
upstream_url: String,
spec: LocalSameFormatProviderSpec,
) -> String {
if spec.family != LocalSameFormatProviderFamily::Gemini || !spec.require_streaming {
return upstream_url;
}
let has_alt = upstream_url
.split_once('?')
.map(|(_, query)| {
form_urlencoded::parse(query.as_bytes())
.any(|(key, _)| key.as_ref().eq_ignore_ascii_case("alt"))
})
.unwrap_or(false);
if has_alt {
return upstream_url;
}
if upstream_url.contains('?') {
format!("{upstream_url}&alt=sse")
} else {
format!("{upstream_url}?alt=sse")
}
}

View File

@@ -0,0 +1,65 @@
use std::collections::BTreeMap;
use aether_contracts::ExecutionPlan;
pub(crate) use crate::gateway::ai_pipeline::planner::generic_decision_missing_exact_provider_request;
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) struct LocalSyncPlanAndReport {
pub(crate) plan: ExecutionPlan,
pub(crate) report_kind: Option<String>,
pub(crate) report_context: Option<serde_json::Value>,
}
pub(crate) struct LocalStreamPlanAndReport {
pub(crate) plan: ExecutionPlan,
pub(crate) report_kind: Option<String>,
pub(crate) report_context: Option<serde_json::Value>,
}
#[path = "standard/gemini/plan_builders.rs"]
mod gemini_builders;
#[path = "standard/openai/plan_builders.rs"]
mod openai_builders;
#[path = "passthrough/plan_builders.rs"]
mod passthrough_builders;
#[path = "standard/plan_builders.rs"]
mod standard_builders;
pub(crate) use gemini_builders::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
};
pub(crate) use openai_builders::{
build_openai_chat_stream_plan_from_decision, build_openai_chat_sync_plan_from_decision,
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
};
pub(crate) use passthrough_builders::{
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
};
pub(crate) use standard_builders::{
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
};
pub(super) fn augment_sync_report_context(
report_context: Option<serde_json::Value>,
provider_request_headers: &BTreeMap<String, String>,
provider_request_body: &serde_json::Value,
) -> Result<Option<serde_json::Value>, GatewayError> {
let mut report_context = match report_context {
Some(serde_json::Value::Object(map)) => map,
Some(_) => serde_json::Map::new(),
None => serde_json::Map::new(),
};
report_context.insert(
"provider_request_headers".to_string(),
serde_json::to_value(provider_request_headers)
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
report_context.insert(
"provider_request_body".to_string(),
provider_request_body.clone(),
);
Ok(Some(serde_json::Value::Object(report_context)))
}

View File

@@ -0,0 +1,877 @@
use std::collections::BTreeMap;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use axum::body::Body;
use axum::http::Response;
use serde_json::json;
use tracing::warn;
use uuid::Uuid;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_body_rules, apply_local_header_rules, build_gemini_files_passthrough_url,
build_passthrough_headers_with_auth, resolve_local_gemini_auth,
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, supports_local_gemini_transport_with_network,
};
use crate::gateway::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{
list_selectable_candidates_for_required_capability_without_requested_model,
GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::{
execute_execution_runtime_stream, execute_execution_runtime_sync, AppState,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
};
const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:chat";
const GEMINI_FILES_CLIENT_API_FORMAT: &str = "gemini:files";
const GEMINI_FILES_REQUIRED_CAPABILITY: &str = "gemini_files";
#[derive(Debug, Clone, Copy)]
struct LocalGeminiFilesSpec {
decision_kind: &'static str,
report_kind: Option<&'static str>,
require_streaming: bool,
}
#[derive(Debug, Clone)]
struct LocalGeminiFilesDecisionInput {
auth_context: crate::gateway::GatewayControlAuthContext,
auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
struct LocalGeminiFilesCandidateAttempt {
candidate: GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: String,
}
pub(crate) async fn maybe_execute_sync_via_local_gemini_files_decision(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports = build_local_sync_plan_and_reports(
state,
parts,
body_json,
body_base64,
body_is_empty,
trace_id,
decision,
spec,
)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_sync(
state,
parts.uri.path(),
plan_and_report.plan,
trace_id,
decision,
plan_kind,
plan_and_report.report_kind,
plan_and_report.report_context,
)
.await?
{
mark_unused_local_files_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn maybe_execute_stream_via_local_gemini_files_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_stream_plan_and_reports(state, parts, trace_id, decision, spec).await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_stream(
state,
plan_and_report.plan,
trace_id,
decision,
plan_kind,
plan_and_report.report_kind,
plan_and_report.report_context,
)
.await?
{
mark_unused_local_files_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_sync_local_gemini_files_decision_payload(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
else {
return Ok(None);
};
let attempts =
materialize_local_gemini_files_candidate_attempts(state, trace_id, &input).await?;
for attempt in attempts {
if let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
state,
parts,
body_json,
body_base64,
body_is_empty,
trace_id,
&input,
attempt,
spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_stream_local_gemini_files_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
else {
return Ok(None);
};
let attempts =
materialize_local_gemini_files_candidate_attempts(state, trace_id, &input).await?;
let empty_body_json = serde_json::Value::Null;
for attempt in attempts {
if let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
state,
parts,
&empty_body_json,
None,
true,
trace_id,
&input,
attempt,
spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
fn resolve_sync_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
match plan_kind {
GEMINI_FILES_UPLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_UPLOAD_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_LIST_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_LIST_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_GET_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_GET_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_DELETE_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_DELETE_PLAN_KIND,
report_kind: Some("gemini_files_delete_mapping"),
require_streaming: false,
}),
_ => None,
}
}
fn resolve_stream_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
match plan_kind {
GEMINI_FILES_DOWNLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_DOWNLOAD_PLAN_KIND,
report_kind: None,
require_streaming: true,
}),
_ => None,
}
}
async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
trace_id: &str,
decision: &GatewayControlDecision,
spec: LocalGeminiFilesSpec,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_gemini_files_candidate_attempts(state, trace_id, &input).await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
state,
parts,
body_json,
body_base64,
body_is_empty,
trace_id,
&input,
attempt,
spec,
)
.await
else {
continue;
};
match build_passthrough_sync_plan_from_decision(parts, payload) {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = spec.decision_kind,
error = ?err,
"gateway local gemini files sync decision plan build failed"
);
}
}
}
Ok(plans)
}
async fn build_local_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
spec: LocalGeminiFilesSpec,
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_gemini_files_candidate_attempts(state, trace_id, &input).await?;
let mut plans = Vec::new();
let empty_body_json = serde_json::Value::Null;
for attempt in attempts {
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
state,
parts,
&empty_body_json,
None,
true,
trace_id,
&input,
attempt,
spec,
)
.await
else {
continue;
};
match build_passthrough_stream_plan_from_decision(parts, payload) {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = spec.decision_kind,
error = ?err,
"gateway local gemini files stream decision plan build failed"
);
}
}
}
Ok(plans)
}
async fn resolve_local_gemini_files_decision_input(
state: &AppState,
trace_id: &str,
decision: &GatewayControlDecision,
) -> Option<LocalGeminiFilesDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
return None;
};
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
current_unix_secs(),
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => return None,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local gemini files decision auth snapshot read failed"
);
return None;
}
};
Some(LocalGeminiFilesDecisionInput {
auth_context,
auth_snapshot,
})
}
async fn materialize_local_gemini_files_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalGeminiFilesDecisionInput,
) -> Result<Vec<LocalGeminiFilesCandidateAttempt>, GatewayError> {
let candidates = list_selectable_candidates_for_required_capability_without_requested_model(
state,
GEMINI_FILES_CANDIDATE_API_FORMAT,
GEMINI_FILES_REQUIRED_CAPABILITY,
false,
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await?;
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let generated_candidate_id = Uuid::new_v4().to_string();
let extra_data = json!({
"provider_api_format": GEMINI_FILES_CLIENT_API_FORMAT,
"client_api_format": GEMINI_FILES_CLIENT_API_FORMAT,
"candidate_api_format": GEMINI_FILES_CANDIDATE_API_FORMAT,
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
});
let candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: generated_candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => generated_candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local gemini files request candidate upsert failed"
);
generated_candidate_id.clone()
}
};
attempts.push(LocalGeminiFilesCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id,
});
}
Ok(attempts)
}
async fn maybe_build_local_gemini_files_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
body_base64: Option<&str>,
body_is_empty: bool,
trace_id: &str,
input: &LocalGeminiFilesDecisionInput,
attempt: LocalGeminiFilesCandidateAttempt,
spec: LocalGeminiFilesSpec,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalGeminiFilesCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local gemini files provider transport read failed"
);
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
if !supports_local_gemini_transport_with_network(&transport, GEMINI_FILES_CANDIDATE_API_FORMAT)
{
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let Some((auth_header, auth_value)) = resolve_local_gemini_auth(&transport) else {
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
};
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let passthrough_path = custom_path.unwrap_or(parts.uri.path());
let upstream_url =
if spec.decision_kind == GEMINI_FILES_UPLOAD_PLAN_KIND || custom_path.is_some() {
build_gemini_files_passthrough_url(
&transport.endpoint.base_url,
passthrough_path,
parts.uri.query(),
)
} else {
build_gemini_files_passthrough_url(
&transport.endpoint.base_url,
passthrough_path,
parts.uri.query(),
)
};
let Some(upstream_url) = upstream_url else {
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"upstream_url_missing",
)
.await;
return None;
};
let mut provider_request_body = if spec.decision_kind == GEMINI_FILES_UPLOAD_PLAN_KIND
&& !body_is_empty
&& body_base64.is_none()
{
Some(body_json.clone())
} else {
None
};
let provider_request_body_base64 = if spec.decision_kind == GEMINI_FILES_UPLOAD_PLAN_KIND {
body_base64
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
} else {
None
};
let original_request_body = if let Some(body_bytes_b64) = provider_request_body_base64.clone() {
json!({"body_bytes_b64": body_bytes_b64})
} else if !body_is_empty {
body_json.clone()
} else {
serde_json::Value::Null
};
if provider_request_body_base64.is_some() && transport.endpoint.body_rules.is_some() {
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_body_rules_unsupported_for_binary_upload",
)
.await;
return None;
}
if let Some(body) = provider_request_body.as_mut() {
if !apply_local_body_rules(
body,
transport.endpoint.body_rules.as_ref(),
Some(body_json),
) {
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_body_rules_apply_failed",
)
.await;
return None;
}
}
let mut provider_request_headers = build_passthrough_headers_with_auth(
&parts.headers,
&auth_header,
&auth_value,
&BTreeMap::new(),
);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[&auth_header, "content-type"],
provider_request_body
.as_ref()
.unwrap_or(&original_request_body),
Some(&original_request_body),
) {
mark_skipped_local_gemini_files_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
let file_name = parts
.uri
.path()
.trim_start_matches("/v1beta/")
.trim()
.to_string();
let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await;
let tls_profile = resolve_transport_tls_profile(&transport);
Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(
crate::gateway::ExecutionStrategy::LocalSameFormat
.as_str()
.to_string(),
),
conversion_mode: Some(crate::gateway::ConversionMode::None.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.clone()),
provider_name: Some(transport.provider.name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url),
provider_request_method: Some(parts.method.to_string()),
auth_header: Some(auth_header),
auth_value: Some(auth_value),
provider_api_format: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
client_api_format: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
provider_contract: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
client_contract: Some(GEMINI_FILES_CLIENT_API_FORMAT.to_string()),
model_name: Some("gemini-files".to_string()),
mapped_model: Some(candidate.selected_provider_model_name.clone()),
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers,
provider_request_body,
provider_request_body_base64,
content_type: parts
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream: spec.require_streaming,
report_kind: spec.report_kind.map(ToOwned::to_owned),
report_context: Some(json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": "gemini-files",
"provider_name": transport.provider.name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"file_key_id": candidate.key_id,
"file_name": file_name,
"provider_api_format": GEMINI_FILES_CLIENT_API_FORMAT,
"client_api_format": GEMINI_FILES_CLIENT_API_FORMAT,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": original_request_body,
"has_envelope": false,
"needs_conversion": false,
})),
auth_context: Some(input.auth_context.clone()),
})
}
async fn mark_skipped_local_gemini_files_candidate(
state: &AppState,
input: &LocalGeminiFilesDecisionInput,
trace_id: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(current_unix_secs()),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local gemini files failed to persist skipped candidate"
);
}
}
async fn mark_unused_local_files_candidates<T>(state: &AppState, remaining: Vec<T>)
where
T: LocalGeminiFilesPlanAndReport,
{
for plan_and_report in remaining {
record_local_request_candidate_status(
state,
plan_and_report.plan(),
plan_and_report.report_context(),
RequestCandidateStatus::Unused,
None,
None,
None,
None,
None,
None,
)
.await;
}
}
trait LocalGeminiFilesPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan;
fn report_context(&self) -> Option<&serde_json::Value>;
}
impl LocalGeminiFilesPlanAndReport for LocalSyncPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_context(&self) -> Option<&serde_json::Value> {
self.report_context.as_ref()
}
}
impl LocalGeminiFilesPlanAndReport for LocalStreamPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_context(&self) -> Option<&serde_json::Value> {
self.report_context.as_ref()
}
}

View File

@@ -0,0 +1,14 @@
//! Non-matrix AI surfaces such as files and video.
mod files;
mod video;
pub(crate) use self::files::{
maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
maybe_execute_stream_via_local_gemini_files_decision,
maybe_execute_sync_via_local_gemini_files_decision,
};
pub(crate) use self::video::{
maybe_build_sync_local_video_decision_payload, maybe_execute_sync_via_local_video_decision,
};

View File

@@ -0,0 +1,770 @@
use std::collections::BTreeMap;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use axum::body::Body;
use axum::http::Response;
use serde_json::{json, Value};
use tracing::warn;
use uuid::Uuid;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_body_rules, apply_local_header_rules, build_gemini_video_predict_long_running_url,
build_passthrough_headers_with_auth, build_passthrough_path_url, resolve_local_gemini_auth,
resolve_local_openai_chat_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
};
use crate::gateway::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_passthrough_sync_plan_from_decision, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::{
execute_execution_runtime_sync, AppState, GatewayControlDecision,
GatewayControlSyncDecisionResponse, GatewayError,
};
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalVideoCreateFamily {
OpenAi,
Gemini,
}
#[derive(Debug, Clone, Copy)]
struct LocalVideoCreateSpec {
api_format: &'static str,
decision_kind: &'static str,
report_kind: &'static str,
family: LocalVideoCreateFamily,
}
#[derive(Debug, Clone)]
struct LocalVideoCreateDecisionInput {
auth_context: crate::gateway::GatewayControlAuthContext,
requested_model: String,
auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
struct LocalVideoCreateCandidateAttempt {
candidate: GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: String,
}
pub(crate) async fn maybe_execute_sync_via_local_video_decision(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_sync_plan_and_reports(state, parts, body_json, trace_id, decision, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_sync(
state,
parts.uri.path(),
plan_and_report.plan,
trace_id,
decision,
plan_kind,
plan_and_report.report_kind,
plan_and_report.report_context,
)
.await?
{
mark_unused_local_video_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_sync_local_video_decision_payload(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_video_create_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(None);
};
let candidates = match list_selectable_candidates(
state,
spec.api_format,
&input.requested_model,
false,
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await
{
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = spec.decision_kind,
error = ?err,
"gateway local video decision scheduler selection failed"
);
return Ok(None);
}
};
let attempts = materialize_local_video_create_candidate_attempts(
state,
trace_id,
&input,
candidates,
spec.api_format,
)
.await;
for attempt in attempts {
if let Some(payload) = maybe_build_local_video_create_decision_payload_for_candidate(
state, parts, body_json, trace_id, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
match plan_kind {
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
api_format: "openai:video",
decision_kind: OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
report_kind: "openai_video_create_sync_finalize",
family: LocalVideoCreateFamily::OpenAi,
}),
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
api_format: "gemini:video",
decision_kind: GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
report_kind: "gemini_video_create_sync_finalize",
family: LocalVideoCreateFamily::Gemini,
}),
_ => None,
}
}
async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
trace_id: &str,
decision: &GatewayControlDecision,
spec: LocalVideoCreateSpec,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
let Some(input) = resolve_local_video_create_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(Vec::new());
};
let candidates = match list_selectable_candidates(
state,
spec.api_format,
&input.requested_model,
false,
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await
{
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = spec.decision_kind,
error = ?err,
"gateway local video decision scheduler selection failed"
);
return Ok(Vec::new());
}
};
let attempts = materialize_local_video_create_candidate_attempts(
state,
trace_id,
&input,
candidates,
spec.api_format,
)
.await;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_video_create_decision_payload_for_candidate(
state, parts, body_json, trace_id, &input, attempt, spec,
)
.await
else {
continue;
};
match build_passthrough_sync_plan_from_decision(parts, payload) {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = spec.decision_kind,
error = ?err,
"gateway local video sync decision plan build failed"
);
}
}
}
Ok(plans)
}
async fn resolve_local_video_create_decision_input(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalVideoCreateSpec,
) -> Option<LocalVideoCreateDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
return None;
};
let requested_model = match spec.family {
LocalVideoCreateFamily::OpenAi => body_json
.get("model")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)?,
LocalVideoCreateFamily::Gemini => extract_gemini_video_model_from_path(parts.uri.path())?,
};
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
current_unix_secs(),
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => return None,
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = spec.decision_kind,
error = ?err,
"gateway local video decision auth snapshot read failed"
);
return None;
}
};
Some(LocalVideoCreateDecisionInput {
auth_context,
requested_model,
auth_snapshot,
})
}
async fn maybe_build_local_video_create_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
body_json: &serde_json::Value,
trace_id: &str,
input: &LocalVideoCreateDecisionInput,
attempt: LocalVideoCreateCandidateAttempt,
spec: LocalVideoCreateSpec,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalVideoCreateCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = spec.decision_kind,
error = ?err,
"gateway local video decision provider transport read failed"
);
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
let transport_supported = match spec.family {
LocalVideoCreateFamily::OpenAi => {
supports_local_standard_transport_with_network(&transport, spec.api_format)
}
LocalVideoCreateFamily::Gemini => {
supports_local_gemini_transport_with_network(&transport, spec.api_format)
}
};
if !transport_supported {
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let auth = match spec.family {
LocalVideoCreateFamily::OpenAi => resolve_local_openai_chat_auth(&transport),
LocalVideoCreateFamily::Gemini => resolve_local_gemini_auth(&transport),
};
let Some((auth_header, auth_value)) = auth else {
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
};
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let upstream_url = build_video_upstream_url(parts, &transport, &mapped_model, spec.family);
let Some(upstream_url) = upstream_url else {
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"upstream_url_missing",
)
.await;
return None;
};
let Some(provider_request_body) = build_provider_request_body(
body_json,
spec.family,
&mapped_model,
transport.endpoint.body_rules.as_ref(),
) else {
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
};
let mut provider_request_headers = build_passthrough_headers_with_auth(
&parts.headers,
&auth_header,
&auth_value,
&BTreeMap::new(),
);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[&auth_header, "content-type"],
&provider_request_body,
Some(body_json),
) {
mark_skipped_local_video_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await;
let tls_profile = resolve_transport_tls_profile(&transport);
Some(GatewayControlSyncDecisionResponse {
action: EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string(),
decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(
crate::gateway::ExecutionStrategy::LocalSameFormat
.as_str()
.to_string(),
),
conversion_mode: Some(crate::gateway::ConversionMode::None.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.clone()),
provider_name: Some(transport.provider.name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url),
provider_request_method: Some(parts.method.to_string()),
auth_header: Some(auth_header),
auth_value: Some(auth_value),
provider_api_format: Some(spec.api_format.to_string()),
client_api_format: Some(spec.api_format.to_string()),
provider_contract: Some(spec.api_format.to_string()),
client_contract: Some(spec.api_format.to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers,
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: parts
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream: false,
report_kind: Some(spec.report_kind.to_string()),
report_context: Some(json!({
"user_id": input.auth_context.user_id.clone(),
"api_key_id": input.auth_context.api_key_id.clone(),
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model.clone(),
"provider_name": transport.provider.name.clone(),
"provider_id": candidate.provider_id.clone(),
"endpoint_id": candidate.endpoint_id.clone(),
"key_id": candidate.key_id.clone(),
"provider_api_format": spec.api_format,
"client_api_format": spec.api_format,
"mapped_model": mapped_model,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": false,
"needs_conversion": false,
})),
auth_context: Some(input.auth_context.clone()),
})
}
fn build_provider_request_body(
body_json: &serde_json::Value,
family: LocalVideoCreateFamily,
mapped_model: &str,
body_rules: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut provider_request_body = match family {
LocalVideoCreateFamily::OpenAi => {
let mut provider_request_body = body_json.as_object().cloned().unwrap_or_default();
provider_request_body
.insert("model".to_string(), Value::String(mapped_model.to_string()));
serde_json::Value::Object(provider_request_body)
}
LocalVideoCreateFamily::Gemini => body_json.clone(),
};
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
fn build_video_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str,
family: LocalVideoCreateFamily,
) -> Option<String> {
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(path) = custom_path {
let blocked_keys = match family {
LocalVideoCreateFamily::OpenAi => &[][..],
LocalVideoCreateFamily::Gemini => &["key"][..],
};
return build_passthrough_path_url(
&transport.endpoint.base_url,
path,
parts.uri.query(),
blocked_keys,
);
}
match family {
LocalVideoCreateFamily::OpenAi => build_passthrough_path_url(
&transport.endpoint.base_url,
parts.uri.path(),
parts.uri.query(),
&[],
),
LocalVideoCreateFamily::Gemini => build_gemini_video_predict_long_running_url(
&transport.endpoint.base_url,
mapped_model,
parts.uri.query(),
),
}
}
async fn materialize_local_video_create_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalVideoCreateDecisionInput,
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
api_format: &str,
) -> Vec<LocalVideoCreateCandidateAttempt> {
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let generated_candidate_id = Uuid::new_v4().to_string();
let extra_data = json!({
"provider_api_format": api_format,
"client_api_format": api_format,
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
});
let candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: generated_candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => generated_candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
decision_api_format = api_format,
error = ?err,
"gateway local video decision request candidate upsert failed"
);
generated_candidate_id.clone()
}
};
attempts.push(LocalVideoCreateCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id,
});
}
attempts
}
async fn mark_skipped_local_video_candidate(
state: &AppState,
input: &LocalVideoCreateDecisionInput,
trace_id: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
let terminal_unix_secs = current_unix_secs();
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(terminal_unix_secs),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local video decision failed to persist skipped candidate"
);
}
}
async fn mark_unused_local_video_candidates(
state: &AppState,
remaining: Vec<LocalSyncPlanAndReport>,
) {
for plan_and_report in remaining {
record_local_request_candidate_status(
state,
&plan_and_report.plan,
plan_and_report.report_context.as_ref(),
RequestCandidateStatus::Unused,
None,
None,
None,
None,
None,
None,
)
.await;
}
}
fn extract_gemini_video_model_from_path(path: &str) -> Option<String> {
let suffix = path.strip_prefix("/v1beta/models/")?;
let model = suffix.split(':').next()?.trim();
if model.is_empty() {
return None;
}
Some(model.to_string())
}

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
report_kind: "claude_chat_sync_finalize",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Chat,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:chat",
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
report_kind: "claude_chat_stream_success",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Chat,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
report_kind: "claude_cli_sync_finalize",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Cli,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:cli",
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
report_kind: "claude_cli_stream_success",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Cli,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,104 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::{
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use super::family::{
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
maybe_execute_stream_via_standard_family_decision,
maybe_execute_sync_via_standard_family_decision,
};
pub(crate) use crate::gateway::ai_pipeline::conversion::request::normalize_claude_request_to_openai_chat_request;
mod chat;
mod cli;
pub(crate) async fn maybe_execute_sync_via_local_claude_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_sync_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_execute_stream_via_local_claude_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_stream_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_sync_local_claude_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_sync_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_stream_local_claude_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_stream_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}

View File

@@ -0,0 +1,271 @@
use std::collections::BTreeSet;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use uuid::Uuid;
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayError,
};
use super::types::{
LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSourceFamily,
LocalStandardSourceMode, LocalStandardSpec,
};
pub(super) async fn resolve_local_standard_decision_input(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Option<LocalStandardDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
return None;
};
let requested_model = match spec.family {
LocalStandardSourceFamily::Standard => body_json
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)?,
LocalStandardSourceFamily::Gemini => extract_gemini_model_from_path(parts.uri.path())?,
};
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
current_unix_secs(),
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => return None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard decision auth snapshot read failed"
);
return None;
}
};
Some(LocalStandardDecisionInput {
auth_context,
requested_model,
auth_snapshot,
})
}
pub(super) async fn materialize_local_standard_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalStandardDecisionInput,
spec: LocalStandardSpec,
) -> Result<Vec<LocalStandardCandidateAttempt>, GatewayError> {
let mut seen_candidates = BTreeSet::new();
let mut candidates = Vec::new();
for candidate_api_format in candidate_api_formats_for_spec(spec) {
let auth_snapshot = if *candidate_api_format == spec.api_format {
Some(&input.auth_snapshot)
} else {
None
};
let mut selected_candidates = list_selectable_candidates(
state,
candidate_api_format,
&input.requested_model,
spec.require_streaming,
auth_snapshot,
current_unix_secs(),
)
.await?;
if auth_snapshot.is_none() {
selected_candidates.retain(|candidate| {
auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
candidate,
)
});
}
for candidate in selected_candidates {
let candidate_key = format!(
"{}:{}:{}:{}:{}:{}",
candidate.provider_id,
candidate.endpoint_id,
candidate.key_id,
candidate.model_id,
candidate.selected_provider_model_name,
candidate.endpoint_api_format,
);
if seen_candidates.insert(candidate_key) {
candidates.push(candidate);
}
}
}
let candidates =
crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates(state, candidates)
.await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let candidate_id = Uuid::new_v4().to_string();
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
let execution_strategy = if provider_api_format == spec.api_format {
ExecutionStrategy::LocalSameFormat
} else {
ExecutionStrategy::LocalCrossFormat
};
let conversion_mode = if crate::gateway::ai_pipeline::conversion::request_conversion_kind(
spec.api_format,
provider_api_format.as_str(),
)
.is_some()
{
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
let extra_data = append_execution_contract_fields_to_value(
json!({
"provider_api_format": provider_api_format,
"client_api_format": spec.api_format,
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
}),
execution_strategy,
conversion_mode,
spec.api_format,
candidate.endpoint_api_format.as_str(),
);
let stored_candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard decision request candidate upsert failed"
);
candidate_id.clone()
}
};
attempts.push(LocalStandardCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id: stored_candidate_id,
});
}
Ok(attempts)
}
fn auth_snapshot_allows_cross_format_candidate(
auth_snapshot: &crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
requested_model: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> bool {
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
let provider_allowed = allowed_providers.iter().any(|value| {
value
.trim()
.eq_ignore_ascii_case(candidate.provider_id.trim())
|| value
.trim()
.eq_ignore_ascii_case(candidate.provider_name.trim())
});
if !provider_allowed {
return false;
}
}
if let Some(allowed_models) = auth_snapshot.effective_allowed_models() {
let model_allowed = allowed_models
.iter()
.any(|value| value == requested_model || value == &candidate.global_model_name);
if !model_allowed {
return false;
}
}
true
}
fn candidate_api_formats_for_spec(spec: LocalStandardSpec) -> &'static [&'static str] {
match spec.mode {
LocalStandardSourceMode::Chat | LocalStandardSourceMode::Cli => &[
"openai:chat",
"openai:cli",
"openai:compact",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
],
}
}
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
let tail = &path[start..];
let end = tail.find(':').unwrap_or(tail.len());
let model = tail[..end].trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}

View File

@@ -0,0 +1,236 @@
use axum::body::Body;
use axum::http::Response;
use tracing::warn;
use crate::gateway::ai_pipeline::planner::family_core::{
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::{AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError};
use super::candidates::{
materialize_local_standard_candidate_attempts, resolve_local_standard_decision_input,
};
use super::payload::maybe_build_local_standard_decision_payload_for_candidate;
use super::types::{LocalStandardSourceFamily, LocalStandardSpec};
pub(crate) async fn maybe_execute_sync_via_standard_family_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_sync_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_sync_plan_and_reports(state, parts, trace_id, decision, plan_kind, plan_and_reports)
.await
}
pub(crate) async fn maybe_execute_stream_via_standard_family_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_stream_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
}
pub(crate) async fn maybe_build_sync_via_standard_family_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
for attempt in attempts {
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_stream_via_standard_family_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(None);
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
for attempt in attempts {
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalStandardSourceFamily::Standard => {
build_standard_sync_plan_from_decision(parts, body_json, payload)
}
LocalStandardSourceFamily::Gemini => {
build_gemini_sync_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard sync plan build failed"
);
}
}
}
Ok(plans)
}
async fn build_local_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
let built = match spec.family {
LocalStandardSourceFamily::Standard => {
build_standard_stream_plan_from_decision(parts, body_json, payload, false)
}
LocalStandardSourceFamily::Gemini => {
build_gemini_stream_plan_from_decision(parts, body_json, payload)
}
};
match built {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard stream plan build failed"
);
}
}
}
Ok(plans)
}

View File

@@ -0,0 +1,13 @@
mod candidates;
mod execute;
mod payload;
mod types;
pub(crate) use self::execute::{
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
maybe_execute_stream_via_standard_family_decision,
maybe_execute_sync_via_standard_family_decision,
};
pub(crate) use self::types::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};

View File

@@ -0,0 +1,360 @@
use std::collections::BTreeMap;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_header_rules, build_openai_passthrough_headers, ensure_upstream_auth_header,
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, LocalResolvedOAuthRequestAuth,
};
use crate::gateway::request_candidates::current_unix_secs;
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlSyncDecisionResponse, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
use super::types::{LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSpec};
pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalStandardDecisionInput,
attempt: LocalStandardCandidateAttempt,
spec: LocalStandardSpec,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalStandardCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
let Some(conversion_kind) = crate::gateway::ai_pipeline::conversion::request_conversion_kind(
spec.api_format,
provider_api_format.as_str(),
) else {
if provider_api_format == spec.api_format {
return None;
}
return None;
};
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local standard decision provider transport read failed"
);
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
if !crate::gateway::ai_pipeline::conversion::request_conversion_transport_supported(
&transport,
conversion_kind,
) {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let resolved_auth = crate::gateway::ai_pipeline::conversion::request_conversion_direct_auth(
&transport,
conversion_kind,
);
let oauth_auth = if resolved_auth.is_none() {
match state.resolve_local_oauth_request_auth(&transport).await {
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
Ok(None) => None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
provider_type = %transport.provider.provider_type,
error = ?err,
"gateway local standard oauth auth resolution failed"
);
None
}
}
} else {
None
};
let Some((auth_header, auth_value)) = resolved_auth.or(oauth_auth) else {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
};
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let provider_request_body =
match crate::gateway::ai_pipeline::planner::standard::build_standard_request_body(
body_json,
spec.api_format,
&mapped_model,
provider_api_format.as_str(),
parts.uri.path(),
spec.require_streaming,
transport.endpoint.body_rules.as_ref(),
) {
Some(body) => body,
None => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
};
let upstream_url =
match crate::gateway::ai_pipeline::planner::standard::build_standard_upstream_url(
parts,
&transport,
&mapped_model,
provider_api_format.as_str(),
spec.require_streaming,
) {
Some(url) => url,
None => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"upstream_url_missing",
)
.await;
return None;
}
};
let mut provider_request_headers = build_openai_passthrough_headers(
&parts.headers,
&auth_header,
&auth_value,
&BTreeMap::new(),
Some("application/json"),
);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[&auth_header, "content-type"],
&provider_request_body,
Some(body_json),
) {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
if spec.require_streaming {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(ExecutionStrategy::LocalCrossFormat.as_str().to_string()),
conversion_mode: Some(ConversionMode::Bidirectional.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.clone()),
provider_name: Some(candidate.provider_name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url.clone()),
provider_request_method: None,
auth_header: Some(auth_header),
auth_value: Some(auth_value),
provider_api_format: Some(provider_api_format.clone()),
client_api_format: Some(spec.api_format.to_string()),
provider_contract: Some(provider_api_format.clone()),
client_contract: Some(spec.api_format.to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: provider_request_headers.clone(),
provider_request_body: Some(provider_request_body.clone()),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy: resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await,
tls_profile: resolve_transport_tls_profile(&transport),
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream: spec.require_streaming,
report_kind: Some(spec.report_kind.to_string()),
report_context: Some(append_execution_contract_fields_to_value(
json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model,
"provider_name": candidate.provider_name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"provider_api_format": provider_api_format,
"client_api_format": spec.api_format,
"mapped_model": mapped_model,
"upstream_url": upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": provider_request_headers,
"provider_request_body": provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": false,
"needs_conversion": true,
}),
ExecutionStrategy::LocalCrossFormat,
ConversionMode::Bidirectional,
spec.api_format,
candidate.endpoint_api_format.as_str(),
)),
auth_context: Some(input.auth_context.clone()),
})
}
pub(super) async fn mark_skipped_local_standard_candidate(
state: &AppState,
input: &LocalStandardDecisionInput,
trace_id: &str,
candidate: &crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(current_unix_secs()),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local standard decision failed to persist skipped candidate"
);
}
}

View File

@@ -0,0 +1,35 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalStandardSourceFamily {
Standard,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalStandardSourceMode {
Chat,
Cli,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LocalStandardSpec {
pub(crate) api_format: &'static str,
pub(crate) decision_kind: &'static str,
pub(crate) report_kind: &'static str,
pub(crate) family: LocalStandardSourceFamily,
pub(crate) mode: LocalStandardSourceMode,
pub(crate) require_streaming: bool,
}
#[derive(Debug, Clone)]
pub(super) struct LocalStandardDecisionInput {
pub(super) auth_context: crate::gateway::GatewayControlAuthContext,
pub(super) requested_model: String,
pub(super) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
pub(super) struct LocalStandardCandidateAttempt {
pub(super) candidate: crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate,
pub(super) candidate_index: u32,
pub(super) candidate_id: String,
}

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
report_kind: "gemini_chat_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:chat",
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
report_kind: "gemini_chat_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,33 @@
use crate::gateway::ai_pipeline::planner::{
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
};
use super::super::family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
report_kind: "gemini_cli_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:cli",
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
report_kind: "gemini_cli_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: true,
}),
_ => None,
}
}

View File

@@ -0,0 +1,104 @@
use axum::body::Body;
use axum::http::Response;
use crate::gateway::{
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use super::family::{
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
maybe_execute_stream_via_standard_family_decision,
maybe_execute_sync_via_standard_family_decision,
};
pub(crate) use crate::gateway::ai_pipeline::conversion::request::normalize_gemini_request_to_openai_chat_request;
mod chat;
mod cli;
pub(crate) async fn maybe_execute_sync_via_local_gemini_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_sync_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_execute_stream_via_local_gemini_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
maybe_execute_stream_via_standard_family_decision(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_sync_local_gemini_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_sync_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
},
)
.await
}
pub(crate) async fn maybe_build_stream_local_gemini_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
maybe_build_stream_via_standard_family_payload(
state,
parts,
trace_id,
decision,
body_json,
plan_kind,
|plan_kind| {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
},
)
.await
}

View File

@@ -0,0 +1,242 @@
use aether_contracts::{ExecutionPlan, RequestBody};
use super::{
augment_sync_report_context, generic_decision_missing_exact_provider_request,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::provider_transport::ensure_upstream_auth_header;
use crate::gateway::{GatewayControlSyncDecisionResponse, GatewayError};
pub(crate) fn build_gemini_sync_plan_from_decision(
_parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalSyncPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if payload.upstream_is_stream {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: payload.upstream_is_stream,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_gemini_stream_plan_from_decision(
_parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalStreamPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: true,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}

View File

@@ -0,0 +1,258 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use super::{
claude::normalize_claude_request_to_openai_chat_request,
gemini::normalize_gemini_request_to_openai_chat_request,
};
use crate::gateway::ai_pipeline::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request,
normalize_openai_cli_request_to_openai_chat_request,
};
use crate::gateway::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
use crate::gateway::provider_transport::{
apply_local_body_rules, build_claude_messages_url, build_gemini_content_url,
build_openai_chat_url, build_openai_cli_url, build_passthrough_path_url,
};
pub(crate) fn build_standard_request_body(
body_json: &Value,
client_api_format: &str,
mapped_model: &str,
provider_api_format: &str,
request_path: &str,
upstream_is_stream: bool,
body_rules: Option<&Value>,
) -> Option<Value> {
let canonical_request = normalize_standard_request_to_openai_chat_request(
body_json,
client_api_format,
request_path,
)?;
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
let mut provider_request_body = match conversion_kind {
RequestConversionKind::ToOpenAIChat => {
build_openai_chat_request_body(&canonical_request, mapped_model, upstream_is_stream)?
}
RequestConversionKind::ToOpenAIFamilyCli => {
convert_openai_chat_request_to_openai_cli_request(
&canonical_request,
mapped_model,
upstream_is_stream,
false,
)?
}
RequestConversionKind::ToOpenAICompact => {
convert_openai_chat_request_to_openai_cli_request(
&canonical_request,
mapped_model,
false,
true,
)?
}
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
&canonical_request,
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
&canonical_request,
mapped_model,
upstream_is_stream,
)?,
};
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
pub(crate) fn build_standard_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<String> {
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match custom_path {
Some(path) => {
build_passthrough_path_url(&transport.endpoint.base_url, path, parts.uri.query(), &[])
}
None => match provider_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => Some(build_openai_chat_url(
&transport.endpoint.base_url,
parts.uri.query(),
)),
"openai:cli" => Some(build_openai_cli_url(
&transport.endpoint.base_url,
parts.uri.query(),
false,
)),
"openai:compact" => Some(build_openai_cli_url(
&transport.endpoint.base_url,
parts.uri.query(),
true,
)),
"claude:chat" | "claude:cli" => Some(build_claude_messages_url(
&transport.endpoint.base_url,
parts.uri.query(),
)),
"gemini:chat" | "gemini:cli" => build_gemini_content_url(
&transport.endpoint.base_url,
mapped_model,
upstream_is_stream,
parts.uri.query(),
),
_ => None,
},
}
}
pub(crate) fn normalize_standard_request_to_openai_chat_request(
body_json: &Value,
client_api_format: &str,
request_path: &str,
) -> Option<Value> {
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => Some(body_json.clone()),
"openai:cli" | "openai:compact" => {
normalize_openai_cli_request_to_openai_chat_request(body_json)
}
"claude:chat" | "claude:cli" => normalize_claude_request_to_openai_chat_request(body_json),
"gemini:chat" | "gemini:cli" => {
normalize_gemini_request_to_openai_chat_request(body_json, request_path)
}
_ => None,
}
}
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::*;
#[test]
fn builds_openai_chat_request_from_claude_chat_source() {
let request = json!({
"model": "claude-3-7-sonnet",
"system": "You are concise.",
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "Hello from Claude"}]
}
],
"max_tokens": 128
});
let converted = build_standard_request_body(
&request,
"claude:chat",
"gpt-5",
"openai:chat",
"/v1/messages",
false,
None,
)
.expect("claude chat should convert to openai chat");
assert_eq!(converted["model"], "gpt-5");
assert_eq!(converted["messages"][0]["role"], "system");
assert_eq!(converted["messages"][0]["content"], "You are concise.");
assert_eq!(converted["messages"][1]["role"], "user");
assert_eq!(converted["messages"][1]["content"], "Hello from Claude");
}
#[test]
fn builds_claude_chat_request_from_gemini_chat_source() {
let request = json!({
"systemInstruction": {
"parts": [{"text": "Be brief."}]
},
"contents": [
{
"role": "user",
"parts": [{"text": "Hello from Gemini"}]
}
]
});
let converted = build_standard_request_body(
&request,
"gemini:chat",
"claude-sonnet-4-5",
"claude:chat",
"/v1beta/models/gemini-2.5-pro:generateContent",
false,
None,
)
.expect("gemini chat should convert to claude chat");
assert_eq!(converted["model"], "claude-sonnet-4-5");
assert_eq!(converted["messages"][0]["role"], "user");
assert!(
converted["messages"]
.to_string()
.contains("Hello from Gemini"),
"converted claude payload should retain the gemini user text: {converted}"
);
}
#[test]
fn builds_gemini_cli_request_from_claude_cli_source() {
let request = json!({
"model": "claude-sonnet-4-5",
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "Need CLI output"}]
}
],
"max_tokens": 64
});
let converted = build_standard_request_body(
&request,
"claude:cli",
"gemini-2.5-pro",
"gemini:cli",
"/v1/messages",
false,
None,
)
.expect("claude cli should convert to gemini cli");
assert_eq!(converted["contents"][0]["role"], "user");
assert_eq!(
converted["contents"][0]["parts"][0]["text"],
"Need CLI output"
);
}
}

View File

@@ -0,0 +1,129 @@
//! Standard contract planning surface.
//!
//! This groups the public standard matrix in one place:
//! request-side conversion, matrix registry, and local standard execution entrypoints.
use axum::body::Body;
use axum::http::Response;
use crate::gateway::{
AppState, GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
pub(crate) mod claude;
mod family;
pub(crate) mod gemini;
mod matrix;
pub(crate) mod openai;
pub(crate) use crate::gateway::ai_pipeline::conversion::{
build_core_error_body_for_client_format, request_conversion_kind,
request_conversion_transport_supported, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};
pub(crate) use self::matrix::{
build_standard_request_body, build_standard_upstream_url,
normalize_standard_request_to_openai_chat_request,
};
pub(crate) use self::openai::{
copy_request_number_field, copy_request_number_field_as,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
maybe_build_stream_local_decision_payload, maybe_build_sync_local_decision_payload,
maybe_execute_stream_via_local_decision, maybe_execute_sync_via_local_decision,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
};
pub(crate) use self::openai::{
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
maybe_execute_stream_via_local_openai_cli_decision,
maybe_execute_sync_via_local_openai_cli_decision,
};
pub(crate) async fn maybe_execute_sync_via_local_standard_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
if let Some(response) = self::claude::maybe_execute_sync_via_local_claude_decision(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(response));
}
self::gemini::maybe_execute_sync_via_local_gemini_decision(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await
}
pub(crate) async fn maybe_execute_stream_via_local_standard_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
if let Some(response) = self::claude::maybe_execute_stream_via_local_claude_decision(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(response));
}
self::gemini::maybe_execute_stream_via_local_gemini_decision(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await
}
pub(crate) async fn maybe_build_sync_local_standard_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if let Some(payload) = self::claude::maybe_build_sync_local_claude_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
self::gemini::maybe_build_sync_local_gemini_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await
}
pub(crate) async fn maybe_build_stream_local_standard_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if let Some(payload) = self::claude::maybe_build_stream_local_claude_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?
{
return Ok(Some(payload));
}
self::gemini::maybe_build_stream_local_gemini_decision_payload(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await
}

View File

@@ -0,0 +1,321 @@
use axum::body::Body;
use axum::http::Response;
use serde_json::{Map, Value};
use tracing::warn;
use crate::gateway::{
execute_execution_runtime_stream, execute_execution_runtime_sync, AppState,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
LocalExecutionRuntimeMissDiagnostic,
};
use crate::gateway::ai_pipeline::planner::{
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
};
use crate::gateway::ai_pipeline::planner::standard::openai::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
parse_openai_tool_result_content,
};
mod decision;
mod plans;
use self::decision::{
mark_unused_local_openai_chat_candidates, materialize_local_openai_chat_candidate_attempts,
maybe_build_local_openai_chat_decision_payload_for_candidate, LocalOpenAiChatDecisionInput,
};
use self::plans::{
build_local_openai_chat_miss_diagnostic, build_local_openai_chat_stream_plan_and_reports,
build_local_openai_chat_sync_plan_and_reports, current_unix_secs,
list_local_openai_chat_candidates, resolve_local_openai_chat_decision_input,
set_local_openai_chat_miss_diagnostic,
};
pub(crate) async fn maybe_execute_sync_via_local_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let plan_and_reports = build_local_openai_chat_sync_plan_and_reports(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
let plan_count = plan_and_reports.len();
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_sync(
state,
parts.uri.path(),
plan_and_report.plan,
trace_id,
decision,
plan_kind,
plan_and_report.report_kind,
plan_and_report.report_context,
)
.await?
{
mark_unused_local_openai_chat_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
LocalExecutionRuntimeMissDiagnostic {
candidate_count: Some(plan_count),
..build_local_openai_chat_miss_diagnostic(
decision,
plan_kind,
body_json.get("model").and_then(|value| value.as_str()),
"execution_runtime_candidates_exhausted",
)
},
);
Ok(None)
}
pub(crate) async fn maybe_execute_stream_via_local_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let plan_and_reports = build_local_openai_chat_stream_plan_and_reports(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
let plan_count = plan_and_reports.len();
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_stream(
state,
plan_and_report.plan,
trace_id,
decision,
plan_kind,
plan_and_report.report_kind,
plan_and_report.report_context,
)
.await?
{
mark_unused_local_openai_chat_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
LocalExecutionRuntimeMissDiagnostic {
candidate_count: Some(plan_count),
..build_local_openai_chat_miss_diagnostic(
decision,
plan_kind,
body_json.get("model").and_then(|value| value.as_str()),
"execution_runtime_candidates_exhausted",
)
},
);
Ok(None)
}
pub(crate) async fn maybe_build_sync_local_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if plan_kind != OPENAI_CHAT_SYNC_PLAN_KIND {
return Ok(None);
}
let Some(input) = resolve_local_openai_chat_decision_input(
state, trace_id, decision, body_json, plan_kind, false,
)
.await
else {
return Ok(None);
};
let candidates = match list_local_openai_chat_candidates(state, &input, false).await {
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat sync decision scheduler selection failed"
);
return Ok(None);
}
};
let attempts =
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await;
for attempt in attempts {
if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
state,
parts,
trace_id,
body_json,
&input,
attempt,
OPENAI_CHAT_SYNC_PLAN_KIND,
"openai_chat_sync_success",
false,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_stream_local_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
if plan_kind != OPENAI_CHAT_STREAM_PLAN_KIND {
return Ok(None);
}
let Some(input) = resolve_local_openai_chat_decision_input(
state, trace_id, decision, body_json, plan_kind, false,
)
.await
else {
return Ok(None);
};
let candidates = match list_local_openai_chat_candidates(state, &input, true).await {
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat stream decision scheduler selection failed"
);
return Ok(None);
}
};
let attempts =
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await;
for attempt in attempts {
if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
state,
parts,
trace_id,
body_json,
&input,
attempt,
OPENAI_CHAT_STREAM_PLAN_KIND,
"openai_chat_stream_success",
true,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
match stop {
Some(Value::String(value)) if !value.trim().is_empty() => {
Some(vec![Value::String(value.clone())])
}
Some(Value::Array(values)) => Some(
values
.iter()
.filter_map(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| Value::String(value.to_string()))
.collect::<Vec<_>>(),
)
.filter(|values| !values.is_empty()),
_ => None,
}
}
pub(crate) fn resolve_openai_chat_max_tokens(request: &Map<String, Value>) -> u64 {
request
.get("max_completion_tokens")
.and_then(value_as_u64)
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
.unwrap_or(4096)
}
pub(crate) fn value_as_u64(value: &Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok()))
}
pub(crate) fn copy_request_number_field(
request: &Map<String, Value>,
target: &mut Map<String, Value>,
key: &str,
) {
copy_request_number_field_as(request, target, key, key);
}
pub(crate) fn copy_request_number_field_as(
request: &Map<String, Value>,
target: &mut Map<String, Value>,
source_key: &str,
target_key: &str,
) {
if let Some(value) = request.get(source_key).cloned() {
if value.is_number() {
target.insert(target_key.to_string(), value);
}
}
}
pub(crate) fn map_openai_reasoning_effort_to_claude_output(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
"medium" => Some("medium"),
"high" | "xhigh" => Some("high"),
_ => None,
}
}
pub(crate) fn map_openai_reasoning_effort_to_gemini_budget(value: &str) -> Option<u64> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some(1024),
"medium" => Some(4096),
"high" => Some(8192),
"xhigh" => Some(16_384),
_ => None,
}
}

View File

@@ -0,0 +1,820 @@
use std::collections::BTreeMap;
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::json;
use tracing::warn;
use uuid::Uuid;
use crate::gateway::ai_pipeline::conversion::{
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
};
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_header_rules, build_openai_passthrough_headers, ensure_upstream_auth_header,
resolve_local_openai_chat_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
supports_local_openai_chat_transport, LocalResolvedOAuthRequestAuth,
};
use crate::gateway::request_candidates::record_local_request_candidate_status;
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::GatewayMinimalCandidateSelectionCandidate;
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlSyncDecisionResponse,
};
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
OPENAI_CHAT_STREAM_PLAN_KIND,
};
use super::plans::current_unix_secs;
use crate::gateway::ai_pipeline::planner::standard::openai::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
build_local_openai_chat_request_body, build_local_openai_chat_upstream_url,
};
#[derive(Debug, Clone)]
pub(super) struct LocalOpenAiChatDecisionInput {
pub(super) auth_context: crate::gateway::GatewayControlAuthContext,
pub(super) requested_model: String,
pub(super) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
pub(super) struct LocalOpenAiChatCandidateAttempt {
pub(super) candidate: GatewayMinimalCandidateSelectionCandidate,
pub(super) candidate_index: u32,
pub(super) candidate_id: String,
}
pub(super) async fn maybe_build_local_openai_chat_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
attempt: LocalOpenAiChatCandidateAttempt,
decision_kind: &str,
report_kind: &str,
upstream_is_stream: bool,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalOpenAiChatCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat decision provider transport read failed"
);
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
let provider_api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
match provider_api_format.as_str() {
"openai:chat" => {
build_same_format_local_openai_chat_decision_payload_for_candidate(
state,
parts,
trace_id,
body_json,
input,
&candidate,
candidate_index,
&candidate_id,
decision_kind,
report_kind,
upstream_is_stream,
&transport,
)
.await
}
"claude:chat" | "gemini:chat" | "openai:cli" | "openai:compact" => {
build_cross_format_local_openai_chat_decision_payload_for_candidate(
state,
parts,
trace_id,
body_json,
input,
&candidate,
candidate_index,
&candidate_id,
decision_kind,
upstream_is_stream,
&transport,
provider_api_format.as_str(),
)
.await
}
_ => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
None
}
}
}
#[allow(clippy::too_many_arguments)]
async fn build_same_format_local_openai_chat_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
decision_kind: &str,
report_kind: &str,
upstream_is_stream: bool,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
) -> Option<GatewayControlSyncDecisionResponse> {
if !supports_local_openai_chat_transport(transport) {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let oauth_auth = if resolve_local_openai_chat_auth(transport).is_none() {
match state.resolve_local_oauth_request_auth(transport).await {
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
Ok(None) => None,
Err(err) => {
warn!(
trace_id = %trace_id,
provider_type = %transport.provider.provider_type,
error = ?err,
"gateway local openai chat oauth auth resolution failed"
);
None
}
}
} else {
None
};
let Some((auth_header, auth_value)) = resolve_local_openai_chat_auth(transport).or(oauth_auth)
else {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
};
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let Some(provider_request_body) = build_local_openai_chat_request_body(
body_json,
&mapped_model,
upstream_is_stream,
transport.endpoint.body_rules.as_ref(),
) else {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_missing",
)
.await;
return None;
};
let Some(upstream_url) = build_local_openai_chat_upstream_url(parts, transport) else {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"upstream_url_missing",
)
.await;
return None;
};
let mut provider_request_headers = build_openai_passthrough_headers(
&parts.headers,
&auth_header,
&auth_value,
&BTreeMap::new(),
Some("application/json"),
);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[&auth_header, "content-type"],
&provider_request_body,
Some(body_json),
) {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
if upstream_is_stream {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, transport).await;
let tls_profile = resolve_transport_tls_profile(transport);
let prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
Some(GatewayControlSyncDecisionResponse {
action: if upstream_is_stream {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(decision_kind.to_string()),
execution_strategy: Some(ExecutionStrategy::LocalSameFormat.as_str().to_string()),
conversion_mode: Some(ConversionMode::None.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.to_string()),
provider_name: Some(transport.provider.name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url.clone()),
provider_request_method: None,
auth_header: Some(auth_header),
auth_value: Some(auth_value),
provider_api_format: Some("openai:chat".to_string()),
client_api_format: Some("openai:chat".to_string()),
provider_contract: Some("openai:chat".to_string()),
client_contract: Some("openai:chat".to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key,
extra_headers: BTreeMap::new(),
provider_request_headers: provider_request_headers.clone(),
provider_request_body: Some(provider_request_body.clone()),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(transport),
upstream_is_stream,
report_kind: Some(report_kind.to_string()),
report_context: Some(append_execution_contract_fields_to_value(
json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model,
"provider_name": transport.provider.name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"provider_api_format": "openai:chat",
"client_api_format": "openai:chat",
"mapped_model": mapped_model,
"upstream_url": upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": provider_request_headers,
"provider_request_body": provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": false,
"needs_conversion": false,
}),
ExecutionStrategy::LocalSameFormat,
ConversionMode::None,
"openai:chat",
"openai:chat",
)),
auth_context: Some(input.auth_context.clone()),
})
}
#[allow(clippy::too_many_arguments)]
async fn build_cross_format_local_openai_chat_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
decision_kind: &str,
upstream_is_stream: bool,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
provider_api_format: &str,
) -> Option<GatewayControlSyncDecisionResponse> {
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
let Some(conversion_kind) =
request_conversion_kind("openai:chat", provider_api_format.as_str())
else {
return None;
};
let transport_supported = request_conversion_transport_supported(transport, conversion_kind);
if !transport_supported {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let resolve_auth = request_conversion_direct_auth(transport, conversion_kind);
let oauth_auth = if resolve_auth.is_none() {
match state.resolve_local_oauth_request_auth(transport).await {
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
Ok(None) => None,
Err(err) => {
warn!(
trace_id = %trace_id,
provider_type = %transport.provider.provider_type,
provider_api_format = %provider_api_format,
error = ?err,
"gateway local openai chat cross-format oauth auth resolution failed"
);
None
}
}
} else {
None
};
let Some((auth_header, auth_value)) = resolve_auth.or(oauth_auth) else {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
};
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let Some(provider_request_body) = build_cross_format_openai_chat_request_body(
body_json,
&mapped_model,
provider_api_format.as_str(),
upstream_is_stream,
transport.endpoint.body_rules.as_ref(),
) else {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_missing",
)
.await;
return None;
};
let Some(upstream_url) = build_cross_format_openai_chat_upstream_url(
parts,
transport,
&mapped_model,
provider_api_format.as_str(),
upstream_is_stream,
) else {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"upstream_url_missing",
)
.await;
return None;
};
let mut provider_request_headers = build_openai_passthrough_headers(
&parts.headers,
&auth_header,
&auth_value,
&BTreeMap::new(),
Some("application/json"),
);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[&auth_header, "content-type"],
&provider_request_body,
Some(body_json),
) {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
if upstream_is_stream {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
let report_kind = if decision_kind == OPENAI_CHAT_STREAM_PLAN_KIND {
"openai_chat_stream_success"
} else {
"openai_chat_sync_finalize"
};
let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, transport).await;
let tls_profile = resolve_transport_tls_profile(transport);
let prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
Some(GatewayControlSyncDecisionResponse {
action: if upstream_is_stream {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(decision_kind.to_string()),
execution_strategy: Some(ExecutionStrategy::LocalCrossFormat.as_str().to_string()),
conversion_mode: Some(ConversionMode::Bidirectional.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.to_string()),
provider_name: Some(transport.provider.name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url.clone()),
provider_request_method: None,
auth_header: Some(auth_header),
auth_value: Some(auth_value),
provider_api_format: Some(provider_api_format.clone()),
client_api_format: Some("openai:chat".to_string()),
provider_contract: Some(provider_api_format.clone()),
client_contract: Some("openai:chat".to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key,
extra_headers: BTreeMap::new(),
provider_request_headers: provider_request_headers.clone(),
provider_request_body: Some(provider_request_body.clone()),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(transport),
upstream_is_stream,
report_kind: Some(report_kind.to_string()),
report_context: Some(append_execution_contract_fields_to_value(
json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model,
"provider_name": transport.provider.name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"provider_api_format": provider_api_format,
"client_api_format": "openai:chat",
"mapped_model": mapped_model,
"upstream_url": upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": provider_request_headers,
"provider_request_body": provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": false,
"needs_conversion": true,
}),
ExecutionStrategy::LocalCrossFormat,
ConversionMode::Bidirectional,
"openai:chat",
provider_api_format.as_str(),
)),
auth_context: Some(input.auth_context.clone()),
})
}
pub(super) async fn mark_skipped_local_openai_chat_candidate(
state: &AppState,
input: &LocalOpenAiChatDecisionInput,
trace_id: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
*diagnostic
.skip_reasons
.entry(skip_reason.to_string())
.or_insert(0) += 1;
*diagnostic.skipped_candidate_count.get_or_insert(0) += 1;
});
let terminal_unix_secs = current_unix_secs();
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(terminal_unix_secs),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local openai chat decision failed to persist skipped candidate"
);
}
}
pub(super) async fn materialize_local_openai_chat_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalOpenAiChatDecisionInput,
candidates: Vec<GatewayMinimalCandidateSelectionCandidate>,
) -> Vec<LocalOpenAiChatCandidateAttempt> {
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let generated_candidate_id = Uuid::new_v4().to_string();
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
let (execution_strategy, conversion_mode) = if provider_api_format == "openai:chat" {
(ExecutionStrategy::LocalSameFormat, ConversionMode::None)
} else {
(
ExecutionStrategy::LocalCrossFormat,
ConversionMode::Bidirectional,
)
};
let extra_data = append_execution_contract_fields_to_value(
json!({
"provider_api_format": provider_api_format,
"client_api_format": "openai:chat",
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
}),
execution_strategy,
conversion_mode,
"openai:chat",
candidate.endpoint_api_format.trim(),
);
let candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: generated_candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => generated_candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat decision request candidate upsert failed"
);
generated_candidate_id.clone()
}
};
attempts.push(LocalOpenAiChatCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id,
});
}
attempts
}
pub(super) async fn mark_unused_local_openai_chat_candidates<T>(state: &AppState, remaining: Vec<T>)
where
T: LocalOpenAiChatPlanAndReport,
{
for plan_and_report in remaining {
record_local_request_candidate_status(
state,
plan_and_report.plan(),
plan_and_report.report_context(),
RequestCandidateStatus::Unused,
None,
None,
None,
None,
None,
None,
)
.await;
}
}
pub(super) trait LocalOpenAiChatPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan;
fn report_context(&self) -> Option<&serde_json::Value>;
}
impl LocalOpenAiChatPlanAndReport for LocalSyncPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_context(&self) -> Option<&serde_json::Value> {
self.report_context.as_ref()
}
}
impl LocalOpenAiChatPlanAndReport for LocalStreamPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_context(&self) -> Option<&serde_json::Value> {
self.report_context.as_ref()
}
}

View File

@@ -0,0 +1,494 @@
use std::collections::{BTreeMap, BTreeSet};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
use super::{
materialize_local_openai_chat_candidate_attempts,
maybe_build_local_openai_chat_decision_payload_for_candidate, AppState, GatewayControlDecision,
GatewayError, LocalExecutionRuntimeMissDiagnostic, LocalOpenAiChatDecisionInput,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_openai_chat_stream_plan_from_decision, build_openai_chat_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::ai_pipeline::planner::{
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
};
pub(super) fn build_local_openai_chat_miss_diagnostic(
decision: &GatewayControlDecision,
plan_kind: &str,
requested_model: Option<&str>,
reason: &str,
) -> LocalExecutionRuntimeMissDiagnostic {
LocalExecutionRuntimeMissDiagnostic {
reason: reason.to_string(),
route_family: decision.route_family.clone(),
route_kind: decision.route_kind.clone(),
public_path: Some(decision.public_path.clone()),
plan_kind: Some(plan_kind.to_string()),
requested_model: requested_model.map(ToOwned::to_owned),
candidate_count: None,
skipped_candidate_count: None,
skip_reasons: BTreeMap::new(),
}
}
pub(super) fn set_local_openai_chat_miss_diagnostic(
state: &AppState,
trace_id: &str,
decision: &GatewayControlDecision,
plan_kind: &str,
requested_model: Option<&str>,
reason: &str,
) {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_openai_chat_miss_diagnostic(decision, plan_kind, requested_model, reason),
);
}
pub(super) async fn build_local_openai_chat_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
if plan_kind != OPENAI_CHAT_SYNC_PLAN_KIND {
return Ok(Vec::new());
}
let Some(input) = resolve_local_openai_chat_decision_input(
state, trace_id, decision, body_json, plan_kind, true,
)
.await
else {
return Ok(Vec::new());
};
let candidates = match list_local_openai_chat_candidates(state, &input, false).await {
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat sync decision scheduler selection failed"
);
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
"scheduler_selection_failed",
);
return Ok(Vec::new());
}
};
if candidates.is_empty() {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
LocalExecutionRuntimeMissDiagnostic {
candidate_count: Some(0),
..build_local_openai_chat_miss_diagnostic(
decision,
plan_kind,
Some(input.requested_model.as_str()),
"candidate_list_empty",
)
},
);
return Ok(Vec::new());
}
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
LocalExecutionRuntimeMissDiagnostic {
candidate_count: Some(candidates.len()),
..build_local_openai_chat_miss_diagnostic(
decision,
plan_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
)
},
);
let attempts =
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
state,
parts,
trace_id,
body_json,
&input,
attempt,
OPENAI_CHAT_SYNC_PLAN_KIND,
"openai_chat_sync_success",
false,
)
.await
else {
continue;
};
match build_openai_chat_sync_plan_from_decision(parts, body_json, payload) {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat sync decision plan build failed"
);
}
}
}
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
let candidate_count = diagnostic.candidate_count.unwrap_or(0);
let skipped_candidate_count = diagnostic.skipped_candidate_count.unwrap_or(0);
diagnostic.reason = if candidate_count > 0 && skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string()
} else {
"no_local_sync_plans".to_string()
};
});
Ok(plans)
}
pub(super) async fn build_local_openai_chat_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
if plan_kind != OPENAI_CHAT_STREAM_PLAN_KIND {
return Ok(Vec::new());
}
let Some(input) = resolve_local_openai_chat_decision_input(
state, trace_id, decision, body_json, plan_kind, true,
)
.await
else {
return Ok(Vec::new());
};
let candidates = match list_local_openai_chat_candidates(state, &input, true).await {
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat stream decision scheduler selection failed"
);
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
"scheduler_selection_failed",
);
return Ok(Vec::new());
}
};
if candidates.is_empty() {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
LocalExecutionRuntimeMissDiagnostic {
candidate_count: Some(0),
..build_local_openai_chat_miss_diagnostic(
decision,
plan_kind,
Some(input.requested_model.as_str()),
"candidate_list_empty",
)
},
);
return Ok(Vec::new());
}
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
LocalExecutionRuntimeMissDiagnostic {
candidate_count: Some(candidates.len()),
..build_local_openai_chat_miss_diagnostic(
decision,
plan_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
)
},
);
let attempts =
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
state,
parts,
trace_id,
body_json,
&input,
attempt,
OPENAI_CHAT_STREAM_PLAN_KIND,
"openai_chat_stream_success",
true,
)
.await
else {
continue;
};
match build_openai_chat_stream_plan_from_decision(parts, body_json, payload) {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat stream decision plan build failed"
);
}
}
}
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
let candidate_count = diagnostic.candidate_count.unwrap_or(0);
let skipped_candidate_count = diagnostic.skipped_candidate_count.unwrap_or(0);
diagnostic.reason = if candidate_count > 0 && skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string()
} else {
"no_local_stream_plans".to_string()
};
});
Ok(plans)
}
pub(super) fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub(super) async fn list_local_openai_chat_candidates(
state: &AppState,
input: &LocalOpenAiChatDecisionInput,
require_streaming: bool,
) -> Result<Vec<GatewayMinimalCandidateSelectionCandidate>, GatewayError> {
let now_unix_secs = current_unix_secs();
let mut combined = Vec::new();
let mut seen = BTreeSet::new();
let api_formats = if require_streaming {
vec!["openai:chat", "claude:chat", "gemini:chat", "openai:cli"]
} else {
vec![
"openai:chat",
"claude:chat",
"gemini:chat",
"openai:cli",
"openai:compact",
]
};
for api_format in api_formats {
let auth_snapshot = if api_format == "openai:chat" {
Some(&input.auth_snapshot)
} else {
None
};
let mut candidates = list_selectable_candidates(
state,
api_format,
&input.requested_model,
require_streaming,
auth_snapshot,
now_unix_secs,
)
.await?;
if api_format != "openai:chat" {
candidates.retain(|candidate| {
auth_snapshot_allows_cross_format_openai_chat_candidate(
&input.auth_snapshot,
&input.requested_model,
candidate,
)
});
}
for candidate in candidates {
let candidate_key = format!(
"{}:{}:{}:{}:{}",
candidate.provider_id,
candidate.endpoint_id,
candidate.key_id,
candidate.model_id,
candidate.selected_provider_model_name,
);
if seen.insert(candidate_key) {
combined.push(candidate);
}
}
}
Ok(combined)
}
fn auth_snapshot_allows_cross_format_openai_chat_candidate(
auth_snapshot: &crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
requested_model: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> bool {
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
let provider_allowed = allowed_providers.iter().any(|value| {
value
.trim()
.eq_ignore_ascii_case(candidate.provider_id.trim())
|| value
.trim()
.eq_ignore_ascii_case(candidate.provider_name.trim())
});
if !provider_allowed {
return false;
}
}
if let Some(allowed_models) = auth_snapshot.effective_allowed_models() {
let model_allowed = allowed_models
.iter()
.any(|value| value == requested_model || value == &candidate.global_model_name);
if !model_allowed {
return false;
}
}
true
}
pub(super) async fn resolve_local_openai_chat_decision_input(
state: &AppState,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
record_miss_diagnostic: bool,
) -> Option<LocalOpenAiChatDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
warn!(
trace_id = %trace_id,
route_class = ?decision.route_class,
route_family = ?decision.route_family,
route_kind = ?decision.route_kind,
"gateway local openai chat decision skipped: missing_auth_context"
);
if record_miss_diagnostic {
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
body_json.get("model").and_then(|value| value.as_str()),
"missing_auth_context",
);
}
return None;
};
let Some(requested_model) = body_json
.get("model")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
else {
warn!(
trace_id = %trace_id,
"gateway local openai chat decision skipped: missing_requested_model"
);
if record_miss_diagnostic {
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
None,
"missing_requested_model",
);
}
return None;
};
let now_unix_secs = current_unix_secs();
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
now_unix_secs,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
warn!(
trace_id = %trace_id,
user_id = %auth_context.user_id,
api_key_id = %auth_context.api_key_id,
"gateway local openai chat decision skipped: auth_snapshot_missing"
);
if record_miss_diagnostic {
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(requested_model.as_str()),
"auth_snapshot_missing",
);
}
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat decision auth snapshot read failed"
);
if record_miss_diagnostic {
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(requested_model.as_str()),
"auth_snapshot_read_failed",
);
}
return None;
}
};
Some(LocalOpenAiChatDecisionInput {
auth_context,
requested_model,
auth_snapshot,
})
}

View File

@@ -0,0 +1,168 @@
use crate::gateway::{
execute_execution_runtime_stream, execute_execution_runtime_sync, AppState,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use axum::body::Body;
use axum::http::Response;
mod decision;
mod plans;
use self::decision::{
mark_unused_local_openai_cli_candidates, materialize_local_openai_cli_candidate_attempts,
maybe_build_local_openai_cli_decision_payload_for_candidate,
resolve_local_openai_cli_decision_input,
};
use self::plans::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports, resolve_stream_spec,
resolve_sync_spec,
};
pub(crate) async fn maybe_execute_sync_via_local_openai_cli_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_sync_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_sync(
state,
parts.uri.path(),
plan_and_report.plan,
trace_id,
decision,
plan_kind,
plan_and_report.report_kind,
plan_and_report.report_context,
)
.await?
{
mark_unused_local_openai_cli_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn maybe_execute_stream_via_local_openai_cli_decision(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let plan_and_reports =
build_local_stream_plan_and_reports(state, parts, trace_id, decision, body_json, spec)
.await?;
if plan_and_reports.is_empty() {
return Ok(None);
}
let mut remaining = plan_and_reports.into_iter();
while let Some(plan_and_report) = remaining.next() {
if let Some(response) = execute_execution_runtime_stream(
state,
plan_and_report.plan,
trace_id,
decision,
plan_kind,
plan_and_report.report_kind,
plan_and_report.report_context,
)
.await?
{
mark_unused_local_openai_cli_candidates(state, remaining.collect()).await;
return Ok(Some(response));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_sync_local_openai_cli_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) =
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
else {
return Ok(None);
};
let attempts =
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, spec).await?;
for attempt in attempts {
if let Some(payload) = maybe_build_local_openai_cli_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}
pub(crate) async fn maybe_build_stream_local_openai_cli_decision_payload(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
plan_kind: &str,
) -> Result<Option<GatewayControlSyncDecisionResponse>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let Some(input) =
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
else {
return Ok(None);
};
let attempts =
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, spec).await?;
for attempt in attempts {
if let Some(payload) = maybe_build_local_openai_cli_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
{
return Ok(Some(payload));
}
}
Ok(None)
}

View File

@@ -0,0 +1,771 @@
use std::collections::{BTreeMap, BTreeSet};
use aether_data::repository::candidates::{RequestCandidateStatus, UpsertRequestCandidateRecord};
use serde_json::{json, Value};
use tracing::warn;
use uuid::Uuid;
use crate::gateway::ai_pipeline::planner::standard::openai::{
build_cross_format_openai_cli_request_body, build_cross_format_openai_cli_upstream_url,
build_local_openai_cli_request_body, build_local_openai_cli_upstream_url,
};
use crate::gateway::ai_pipeline::conversion::{
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
};
use crate::gateway::headers::collect_control_headers;
use crate::gateway::provider_transport::{
apply_local_body_rules, apply_local_header_rules, build_antigravity_safe_v1internal_request,
build_antigravity_static_identity_headers, build_openai_passthrough_headers,
classify_local_antigravity_request_support, ensure_upstream_auth_header,
resolve_local_gemini_auth, resolve_local_standard_auth, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
supports_local_standard_transport_with_network, AntigravityEnvelopeRequestType,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
LocalResolvedOAuthRequestAuth,
};
use crate::gateway::request_candidates::{
current_unix_secs, record_local_request_candidate_status,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::planner::prefer_local_tunnel_owner_candidates;
use crate::gateway::scheduler::{
list_selectable_candidates, GatewayMinimalCandidateSelectionCandidate,
};
use crate::gateway::{
append_execution_contract_fields_to_value, AppState, ConversionMode, ExecutionStrategy,
GatewayControlDecision, GatewayControlSyncDecisionResponse, GatewayError,
};
use crate::gateway::ai_pipeline::planner::{
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";
#[derive(Debug, Clone, Copy)]
pub(super) struct LocalOpenAiCliSpec {
pub(super) api_format: &'static str,
pub(super) decision_kind: &'static str,
pub(super) report_kind: &'static str,
pub(super) compact: bool,
pub(super) require_streaming: bool,
}
#[derive(Debug, Clone)]
pub(super) struct LocalOpenAiCliDecisionInput {
pub(super) auth_context: crate::gateway::GatewayControlAuthContext,
pub(super) requested_model: String,
pub(super) auth_snapshot: crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
}
#[derive(Debug, Clone)]
pub(super) struct LocalOpenAiCliCandidateAttempt {
pub(super) candidate: GatewayMinimalCandidateSelectionCandidate,
pub(super) candidate_index: u32,
pub(super) candidate_id: String,
}
pub(super) async fn resolve_local_openai_cli_decision_input(
state: &AppState,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
) -> Option<LocalOpenAiCliDecisionInput> {
let Some(auth_context) = decision.auth_context.clone().filter(|auth_context| {
!auth_context.user_id.trim().is_empty() && !auth_context.api_key_id.trim().is_empty()
}) else {
return None;
};
let requested_model = body_json
.get("model")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)?;
let auth_snapshot = match state
.read_auth_api_key_snapshot(
&auth_context.user_id,
&auth_context.api_key_id,
current_unix_secs(),
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => return None,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai cli decision auth snapshot read failed"
);
return None;
}
};
Some(LocalOpenAiCliDecisionInput {
auth_context,
requested_model,
auth_snapshot,
})
}
pub(super) async fn materialize_local_openai_cli_candidate_attempts(
state: &AppState,
trace_id: &str,
input: &LocalOpenAiCliDecisionInput,
spec: LocalOpenAiCliSpec,
) -> Result<Vec<LocalOpenAiCliCandidateAttempt>, GatewayError> {
let mut seen_candidates = BTreeSet::new();
let mut candidates = Vec::new();
for candidate_api_format in candidate_api_formats_for_spec(spec) {
let auth_snapshot = if *candidate_api_format == spec.api_format {
Some(&input.auth_snapshot)
} else {
None
};
let mut selected_candidates = list_selectable_candidates(
state,
candidate_api_format,
&input.requested_model,
spec.require_streaming,
auth_snapshot,
current_unix_secs(),
)
.await?;
if auth_snapshot.is_none() {
selected_candidates.retain(|candidate| {
auth_snapshot_allows_cross_format_openai_cli_candidate(
&input.auth_snapshot,
&input.requested_model,
candidate,
)
});
}
for candidate in selected_candidates {
let candidate_key = format!(
"{}:{}:{}:{}:{}:{}",
candidate.provider_id,
candidate.endpoint_id,
candidate.key_id,
candidate.model_id,
candidate.selected_provider_model_name,
candidate.endpoint_api_format,
);
if seen_candidates.insert(candidate_key) {
candidates.push(candidate);
}
}
}
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
let created_at_unix_secs = current_unix_secs();
let mut attempts = Vec::with_capacity(candidates.len());
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let generated_candidate_id = Uuid::new_v4().to_string();
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
let execution_strategy =
if provider_api_format == spec.api_format.trim().to_ascii_lowercase() {
ExecutionStrategy::LocalSameFormat
} else {
ExecutionStrategy::LocalCrossFormat
};
let conversion_mode =
if request_conversion_kind(spec.api_format, provider_api_format.as_str()).is_some() {
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
let extra_data = append_execution_contract_fields_to_value(
json!({
"provider_api_format": provider_api_format,
"client_api_format": spec.api_format,
"global_model_id": candidate.global_model_id.clone(),
"global_model_name": candidate.global_model_name.clone(),
"model_id": candidate.model_id.clone(),
"selected_provider_model_name": candidate.selected_provider_model_name.clone(),
"mapping_matched_model": candidate.mapping_matched_model.clone(),
"provider_name": candidate.provider_name.clone(),
"key_name": candidate.key_name.clone(),
}),
execution_strategy,
conversion_mode,
spec.api_format,
candidate.endpoint_api_format.as_str(),
);
let candidate_id = match state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: generated_candidate_id.clone(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index: candidate_index as u32,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Available,
skip_reason: None,
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: Some(extra_data),
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: Some(created_at_unix_secs),
started_at_unix_secs: None,
finished_at_unix_secs: None,
})
.await
{
Ok(Some(stored)) => stored.id,
Ok(None) => generated_candidate_id.clone(),
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local openai cli decision request candidate upsert failed"
);
generated_candidate_id.clone()
}
};
attempts.push(LocalOpenAiCliCandidateAttempt {
candidate,
candidate_index: candidate_index as u32,
candidate_id,
});
}
Ok(attempts)
}
fn auth_snapshot_allows_cross_format_openai_cli_candidate(
auth_snapshot: &crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
requested_model: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
) -> bool {
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
let provider_allowed = allowed_providers.iter().any(|value| {
value
.trim()
.eq_ignore_ascii_case(candidate.provider_id.trim())
|| value
.trim()
.eq_ignore_ascii_case(candidate.provider_name.trim())
});
if !provider_allowed {
return false;
}
}
if let Some(allowed_models) = auth_snapshot.effective_allowed_models() {
let model_allowed = allowed_models
.iter()
.any(|value| value == requested_model || value == &candidate.global_model_name);
if !model_allowed {
return false;
}
}
true
}
pub(super) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiCliDecisionInput,
attempt: LocalOpenAiCliCandidateAttempt,
spec: LocalOpenAiCliSpec,
) -> Option<GatewayControlSyncDecisionResponse> {
let LocalOpenAiCliCandidateAttempt {
candidate,
candidate_index,
candidate_id,
} = attempt;
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
let transport = match state
.read_provider_transport_snapshot(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
)
.await
{
Ok(Some(snapshot)) => snapshot,
Ok(None) => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_missing",
)
.await;
return None;
}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local openai cli decision provider transport read failed"
);
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_snapshot_read_failed",
)
.await;
return None;
}
};
let is_antigravity = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("antigravity");
let same_format = provider_api_format == spec.api_format.trim().to_ascii_lowercase();
let conversion_kind = request_conversion_kind(spec.api_format, provider_api_format.as_str());
let transport_supported = if same_format {
supports_local_standard_transport_with_network(&transport, provider_api_format.as_str())
} else {
match conversion_kind {
Some(_) if is_antigravity && provider_api_format == "gemini:cli" => true,
Some(kind) => request_conversion_transport_supported(&transport, kind),
None => false,
}
};
if !transport_supported {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
let resolved_auth = if same_format {
match provider_api_format.as_str() {
"gemini:cli" => resolve_local_gemini_auth(&transport),
"claude:cli" | "openai:cli" | "openai:compact" => {
resolve_local_standard_auth(&transport)
}
_ => None,
}
} else {
conversion_kind.and_then(|kind| request_conversion_direct_auth(&transport, kind))
};
let oauth_auth = if resolved_auth.is_none() {
match state.resolve_local_oauth_request_auth(&transport).await {
Ok(Some(LocalResolvedOAuthRequestAuth::Header { name, value })) => Some((name, value)),
Ok(Some(LocalResolvedOAuthRequestAuth::Kiro(_))) => None,
Ok(None) => None,
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
provider_type = %transport.provider.provider_type,
error = ?err,
"gateway local openai cli oauth auth resolution failed"
);
None
}
}
} else {
None
};
let Some((auth_header, auth_value)) = resolved_auth.or(oauth_auth) else {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
};
let mapped_model = candidate.selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"mapped_model_missing",
)
.await;
return None;
}
let needs_bidirectional_conversion = !same_format && conversion_kind.is_some();
let Some(base_provider_request_body) = (if needs_bidirectional_conversion {
build_cross_format_openai_cli_request_body(
body_json,
&mapped_model,
spec.api_format,
provider_api_format.as_str(),
spec.require_streaming,
transport.endpoint.body_rules.as_ref(),
)
} else {
build_local_openai_cli_request_body(
body_json,
&mapped_model,
spec.require_streaming,
transport.endpoint.body_rules.as_ref(),
)
}) else {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
};
let antigravity_auth = if is_antigravity {
match classify_local_antigravity_request_support(
&transport,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestSideSupport::Supported(spec) => Some(spec.auth),
AntigravityRequestSideSupport::Unsupported(_) => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_unsupported",
)
.await;
return None;
}
}
} else {
None
};
let provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
match build_antigravity_safe_v1internal_request(
antigravity_auth,
trace_id,
&mapped_model,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
}
} else {
base_provider_request_body
};
let upstream_is_stream = spec.require_streaming || is_antigravity;
let Some(upstream_url) = (if needs_bidirectional_conversion {
build_cross_format_openai_cli_upstream_url(
parts,
&transport,
&mapped_model,
spec.api_format,
provider_api_format.as_str(),
upstream_is_stream,
)
} else {
build_local_openai_cli_upstream_url(
parts,
&transport,
provider_api_format.as_str() == "openai:compact",
)
}) else {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"upstream_url_missing",
)
.await;
return None;
};
let mut provider_request_headers = build_openai_passthrough_headers(
&parts.headers,
&auth_header,
&auth_value,
&antigravity_auth
.as_ref()
.map(build_antigravity_static_identity_headers)
.unwrap_or_default(),
Some("application/json"),
);
if !apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[&auth_header, "content-type"],
&provider_request_body,
Some(body_json),
) {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
&candidate,
candidate_index,
&candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
if upstream_is_stream {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
let prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let proxy = resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &transport).await;
let tls_profile = resolve_transport_tls_profile(&transport);
let execution_strategy = if provider_api_format == spec.api_format {
ExecutionStrategy::LocalSameFormat
} else {
ExecutionStrategy::LocalCrossFormat
};
let conversion_mode = if needs_bidirectional_conversion {
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string()
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string()
},
decision_kind: Some(spec.decision_kind.to_string()),
execution_strategy: Some(execution_strategy.as_str().to_string()),
conversion_mode: Some(conversion_mode.as_str().to_string()),
request_id: Some(trace_id.to_string()),
candidate_id: Some(candidate_id.clone()),
provider_name: Some(transport.provider.name.clone()),
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
upstream_base_url: Some(transport.endpoint.base_url.clone()),
upstream_url: Some(upstream_url.clone()),
provider_request_method: None,
auth_header: Some(auth_header),
auth_value: Some(auth_value),
provider_api_format: Some(provider_api_format.clone()),
client_api_format: Some(spec.api_format.to_string()),
provider_contract: Some(provider_api_format.clone()),
client_contract: Some(spec.api_format.to_string()),
model_name: Some(input.requested_model.clone()),
mapped_model: Some(mapped_model.clone()),
prompt_cache_key,
extra_headers: BTreeMap::new(),
provider_request_headers: provider_request_headers.clone(),
provider_request_body: Some(provider_request_body.clone()),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream,
report_kind: Some(spec.report_kind.to_string()),
report_context: Some(append_execution_contract_fields_to_value(
json!({
"user_id": input.auth_context.user_id,
"api_key_id": input.auth_context.api_key_id,
"request_id": trace_id,
"candidate_id": candidate_id,
"candidate_index": candidate_index,
"retry_index": 0,
"model": input.requested_model,
"provider_name": transport.provider.name,
"provider_id": candidate.provider_id,
"endpoint_id": candidate.endpoint_id,
"key_id": candidate.key_id,
"provider_api_format": provider_api_format,
"client_api_format": spec.api_format,
"mapped_model": mapped_model,
"upstream_url": upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": provider_request_headers,
"provider_request_body": provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"has_envelope": is_antigravity,
"envelope_name": if is_antigravity {
Some(ANTIGRAVITY_ENVELOPE_NAME)
} else {
None
},
"needs_conversion": needs_bidirectional_conversion,
}),
execution_strategy,
conversion_mode,
spec.api_format,
candidate.endpoint_api_format.as_str(),
)),
auth_context: Some(input.auth_context.clone()),
})
}
fn candidate_api_formats_for_spec(spec: LocalOpenAiCliSpec) -> &'static [&'static str] {
match spec.api_format {
"openai:compact" => &["openai:compact", "openai:cli", "claude:cli", "gemini:cli"],
"openai:cli" => &["openai:cli", "claude:cli", "gemini:cli"],
_ => &[],
}
}
async fn mark_skipped_local_openai_cli_candidate(
state: &AppState,
input: &LocalOpenAiCliDecisionInput,
trace_id: &str,
candidate: &GatewayMinimalCandidateSelectionCandidate,
candidate_index: u32,
candidate_id: &str,
skip_reason: &'static str,
) {
if let Err(err) = state
.upsert_request_candidate(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
request_id: trace_id.to_string(),
user_id: Some(input.auth_context.user_id.clone()),
api_key_id: Some(input.auth_context.api_key_id.clone()),
username: None,
api_key_name: None,
candidate_index,
retry_index: 0,
provider_id: Some(candidate.provider_id.clone()),
endpoint_id: Some(candidate.endpoint_id.clone()),
key_id: Some(candidate.key_id.clone()),
status: RequestCandidateStatus::Skipped,
skip_reason: Some(skip_reason.to_string()),
is_cached: Some(false),
status_code: None,
error_type: None,
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
required_capabilities: candidate.key_capabilities.clone(),
created_at_unix_secs: None,
started_at_unix_secs: None,
finished_at_unix_secs: Some(current_unix_secs()),
})
.await
{
warn!(
trace_id = %trace_id,
candidate_id = %candidate_id,
skip_reason,
error = ?err,
"gateway local openai cli decision failed to persist skipped candidate"
);
}
}
pub(super) async fn mark_unused_local_openai_cli_candidates<T>(state: &AppState, remaining: Vec<T>)
where
T: LocalOpenAiCliPlanAndReport,
{
for plan_and_report in remaining {
record_local_request_candidate_status(
state,
plan_and_report.plan(),
plan_and_report.report_context(),
RequestCandidateStatus::Unused,
None,
None,
None,
None,
None,
None,
)
.await;
}
}
pub(super) trait LocalOpenAiCliPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan;
fn report_context(&self) -> Option<&serde_json::Value>;
}
impl LocalOpenAiCliPlanAndReport for LocalSyncPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_context(&self) -> Option<&serde_json::Value> {
self.report_context.as_ref()
}
}
impl LocalOpenAiCliPlanAndReport for LocalStreamPlanAndReport {
fn plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_context(&self) -> Option<&serde_json::Value> {
self.report_context.as_ref()
}
}

View File

@@ -0,0 +1,144 @@
use tracing::warn;
use super::decision::{
materialize_local_openai_cli_candidate_attempts,
maybe_build_local_openai_cli_decision_payload_for_candidate,
resolve_local_openai_cli_decision_input, LocalOpenAiCliSpec,
};
use crate::gateway::ai_pipeline::planner::plan_builders::{
build_openai_cli_stream_plan_from_decision, build_openai_cli_sync_plan_from_decision,
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::gateway::{AppState, GatewayControlDecision, GatewayError};
use crate::gateway::ai_pipeline::planner::{
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND,
};
pub(super) fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
match plan_kind {
OPENAI_CLI_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:cli",
decision_kind: OPENAI_CLI_SYNC_PLAN_KIND,
report_kind: "openai_cli_sync_success",
compact: false,
require_streaming: false,
}),
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:compact",
decision_kind: OPENAI_COMPACT_SYNC_PLAN_KIND,
report_kind: "openai_cli_sync_success",
compact: true,
require_streaming: false,
}),
_ => None,
}
}
pub(super) fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
match plan_kind {
OPENAI_CLI_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:cli",
decision_kind: OPENAI_CLI_STREAM_PLAN_KIND,
report_kind: "openai_cli_stream_success",
compact: false,
require_streaming: true,
}),
OPENAI_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:compact",
decision_kind: OPENAI_COMPACT_STREAM_PLAN_KIND,
report_kind: "openai_cli_stream_success",
compact: true,
require_streaming: true,
}),
_ => None,
}
}
pub(super) async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalOpenAiCliSpec,
) -> Result<Vec<LocalSyncPlanAndReport>, GatewayError> {
let Some(input) =
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, spec).await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_openai_cli_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
match build_openai_cli_sync_plan_from_decision(parts, body_json, payload, spec.compact) {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local openai cli sync decision plan build failed"
);
}
}
}
Ok(plans)
}
pub(super) async fn build_local_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
decision: &GatewayControlDecision,
body_json: &serde_json::Value,
spec: LocalOpenAiCliSpec,
) -> Result<Vec<LocalStreamPlanAndReport>, GatewayError> {
let Some(input) =
resolve_local_openai_cli_decision_input(state, trace_id, decision, body_json).await
else {
return Ok(Vec::new());
};
let attempts =
materialize_local_openai_cli_candidate_attempts(state, trace_id, &input, spec).await?;
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_openai_cli_decision_payload_for_candidate(
state, parts, trace_id, body_json, &input, attempt, spec,
)
.await
else {
continue;
};
match build_openai_cli_stream_plan_from_decision(parts, body_json, payload, spec.compact) {
Ok(Some(value)) => plans.push(value),
Ok(None) => {}
Err(err) => {
warn!(
trace_id = %trace_id,
api_format = spec.api_format,
error = ?err,
"gateway local openai cli stream decision plan build failed"
);
}
}
}
Ok(plans)
}

View File

@@ -0,0 +1,31 @@
mod chat;
mod cli;
mod normalize_chat;
mod normalize_cli;
pub(crate) use crate::gateway::ai_pipeline::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub(crate) use self::chat::{
copy_request_number_field, copy_request_number_field_as,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
maybe_build_stream_local_decision_payload, maybe_build_sync_local_decision_payload,
maybe_execute_stream_via_local_decision, maybe_execute_sync_via_local_decision,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
};
pub(crate) use self::cli::{
maybe_build_stream_local_openai_cli_decision_payload,
maybe_build_sync_local_openai_cli_decision_payload,
maybe_execute_stream_via_local_openai_cli_decision,
maybe_execute_sync_via_local_openai_cli_decision,
};
pub(crate) use self::normalize_chat::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
build_local_openai_chat_request_body, build_local_openai_chat_upstream_url,
};
pub(crate) use self::normalize_cli::{
build_cross_format_openai_cli_request_body, build_cross_format_openai_cli_upstream_url,
build_local_openai_cli_request_body, build_local_openai_cli_upstream_url,
};

View File

@@ -0,0 +1,5 @@
mod request;
pub(crate) use self::request::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
build_local_openai_chat_request_body, build_local_openai_chat_upstream_url,
};

View File

@@ -0,0 +1,143 @@
use serde_json::Value;
use crate::gateway::ai_pipeline::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request,
};
use crate::gateway::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
use crate::gateway::provider_transport::{
apply_local_body_rules, build_claude_messages_url, build_gemini_content_url,
build_openai_chat_url, build_openai_cli_url, build_passthrough_path_url,
};
pub(crate) fn build_local_openai_chat_request_body(
body_json: &serde_json::Value,
mapped_model: &str,
upstream_is_stream: bool,
body_rules: Option<&serde_json::Value>,
) -> Option<serde_json::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(),
serde_json::Value::String(mapped_model.to_string()),
);
if upstream_is_stream {
provider_request_body.insert("stream".to_string(), serde_json::Value::Bool(true));
}
let mut provider_request_body = serde_json::Value::Object(provider_request_body);
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
pub(crate) fn build_local_openai_chat_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
) -> Option<String> {
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match custom_path {
Some(path) => {
build_passthrough_path_url(&transport.endpoint.base_url, path, parts.uri.query(), &[])
}
None => Some(build_openai_chat_url(
&transport.endpoint.base_url,
parts.uri.query(),
)),
}
}
pub(crate) fn build_cross_format_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
body_rules: Option<&Value>,
) -> Option<Value> {
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
let mut provider_request_body = match conversion_kind {
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
body_json,
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
body_json,
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToOpenAIFamilyCli => {
convert_openai_chat_request_to_openai_cli_request(
body_json,
mapped_model,
upstream_is_stream,
false,
)?
}
RequestConversionKind::ToOpenAICompact => {
convert_openai_chat_request_to_openai_cli_request(body_json, mapped_model, false, true)?
}
_ => return None,
};
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
pub(crate) fn build_cross_format_openai_chat_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<String> {
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match custom_path {
Some(path) => {
build_passthrough_path_url(&transport.endpoint.base_url, path, parts.uri.query(), &[])
}
None => match conversion_kind {
RequestConversionKind::ToClaudeStandard => Some(build_claude_messages_url(
&transport.endpoint.base_url,
parts.uri.query(),
)),
RequestConversionKind::ToGeminiStandard => build_gemini_content_url(
&transport.endpoint.base_url,
mapped_model,
upstream_is_stream,
parts.uri.query(),
),
RequestConversionKind::ToOpenAIFamilyCli => Some(build_openai_cli_url(
&transport.endpoint.base_url,
parts.uri.query(),
false,
)),
RequestConversionKind::ToOpenAICompact => Some(build_openai_cli_url(
&transport.endpoint.base_url,
parts.uri.query(),
true,
)),
_ => None,
},
}
}

View File

@@ -0,0 +1,203 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use url::form_urlencoded;
use crate::gateway::ai_pipeline::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
};
use crate::gateway::ai_pipeline::conversion::{request_conversion_kind, RequestConversionKind};
use crate::gateway::provider_transport::{
apply_local_body_rules, build_antigravity_v1internal_url, build_claude_messages_url,
build_gemini_content_url, build_openai_cli_url, build_passthrough_path_url,
AntigravityRequestUrlAction,
};
pub(crate) fn build_local_openai_cli_request_body(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
body_rules: Option<&Value>,
) -> 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 require_streaming {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
}
let mut provider_request_body = Value::Object(provider_request_body);
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
pub(crate) fn build_cross_format_openai_cli_request_body(
body_json: &Value,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
body_rules: Option<&Value>,
) -> Option<Value> {
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)?;
let mut provider_request_body = match conversion_kind {
RequestConversionKind::ToOpenAIFamilyCli => {
convert_openai_chat_request_to_openai_cli_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
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,
upstream_is_stream,
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
)?,
_ => return None,
};
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
Some(provider_request_body)
}
pub(crate) fn build_local_openai_cli_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
compact: bool,
) -> Option<String> {
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match custom_path {
Some(path) => {
build_passthrough_path_url(&transport.endpoint.base_url, path, parts.uri.query(), &[])
}
None => Some(build_openai_cli_url(
&transport.endpoint.base_url,
parts.uri.query(),
compact,
)),
}
}
pub(crate) fn build_cross_format_openai_cli_upstream_url(
parts: &http::request::Parts,
transport: &crate::gateway::provider_transport::GatewayProviderTransportSnapshot,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<String> {
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("antigravity")
{
let query = parts.uri.query().map(|query| {
form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect::<BTreeMap<String, String>>()
});
return build_antigravity_v1internal_url(
&transport.endpoint.base_url,
if upstream_is_stream {
AntigravityRequestUrlAction::StreamGenerateContent
} else {
AntigravityRequestUrlAction::GenerateContent
},
query.as_ref(),
);
}
let custom_path = transport
.endpoint
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match custom_path {
Some(path) => {
build_passthrough_path_url(&transport.endpoint.base_url, path, parts.uri.query(), &[])
}
None => match conversion_kind {
RequestConversionKind::ToOpenAIFamilyCli => Some(build_openai_cli_url(
&transport.endpoint.base_url,
parts.uri.query(),
false,
)),
RequestConversionKind::ToOpenAICompact => Some(build_openai_cli_url(
&transport.endpoint.base_url,
parts.uri.query(),
true,
)),
RequestConversionKind::ToClaudeStandard => Some(build_claude_messages_url(
&transport.endpoint.base_url,
parts.uri.query(),
)),
RequestConversionKind::ToGeminiStandard => build_gemini_content_url(
&transport.endpoint.base_url,
mapped_model,
upstream_is_stream,
parts.uri.query(),
),
_ => None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_openai_family_cross_format_request_body_from_compact_source() {
let body_json = json!({
"model": "gpt-5",
"input": "hello",
});
let provider_request_body = build_cross_format_openai_cli_request_body(
&body_json,
"gpt-5-upstream",
"openai:compact",
"openai:cli",
false,
None,
)
.expect("compact to openai cli 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");
}
}

View File

@@ -0,0 +1,623 @@
use aether_contracts::{ExecutionPlan, RequestBody};
use super::{
augment_sync_report_context, generic_decision_missing_exact_provider_request,
GatewayControlSyncDecisionResponse, GatewayError, LocalStreamPlanAndReport,
LocalSyncPlanAndReport,
};
use crate::gateway::ai_pipeline::private_surfaces::provider_adaptation_requires_eventstream_accept;
use crate::gateway::provider_transport::{
build_openai_chat_url, build_openai_cli_url, build_openai_passthrough_headers,
ensure_upstream_auth_header,
};
pub(crate) fn build_openai_chat_sync_plan_from_decision(
parts: &http::request::Parts,
body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalSyncPlanAndReport>, GatewayError> {
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(auth_header) = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(auth_value) = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let url = if let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
{
upstream_url
} else {
let Some(upstream_base_url) = payload
.upstream_base_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
build_openai_chat_url(&upstream_base_url, parts.uri.query())
};
let provider_request_body_value = if let Some(body) = payload.provider_request_body.clone() {
body
} else {
let Some(request_body_object) = body_json.as_object() else {
return Ok(None);
};
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
if let Some(mapped_model) = payload
.mapped_model
.as_ref()
.filter(|value| !value.trim().is_empty())
{
provider_request_body.insert(
"model".to_string(),
serde_json::Value::String(mapped_model.clone()),
);
}
if payload.upstream_is_stream {
provider_request_body.insert("stream".to_string(), serde_json::Value::Bool(true));
}
if let Some(prompt_cache_key) = payload
.prompt_cache_key
.as_ref()
.filter(|value| !value.trim().is_empty())
{
let existing = provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(str::trim)
.unwrap_or_default();
if existing.is_empty() {
provider_request_body.insert(
"prompt_cache_key".to_string(),
serde_json::Value::String(prompt_cache_key.clone()),
);
}
}
serde_json::Value::Object(provider_request_body)
};
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
build_openai_passthrough_headers(
&parts.headers,
&auth_header,
&auth_value,
&payload.extra_headers,
payload.content_type.as_deref(),
)
} else {
payload.provider_request_headers.clone()
};
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
if payload.upstream_is_stream {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: payload.upstream_is_stream,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_openai_cli_sync_plan_from_decision(
parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
compact: bool,
) -> Result<Option<LocalSyncPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let url = if let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
{
upstream_url
} else {
let Some(upstream_base_url) = payload
.upstream_base_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
build_openai_cli_url(&upstream_base_url, parts.uri.query(), compact)
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if payload.upstream_is_stream && !provider_request_headers.contains_key("accept") {
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
}
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: payload.upstream_is_stream,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_openai_chat_stream_plan_from_decision(
parts: &http::request::Parts,
body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalStreamPlanAndReport>, GatewayError> {
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(auth_header) = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(auth_value) = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let url = if let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
{
upstream_url
} else {
let Some(upstream_base_url) = payload
.upstream_base_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
build_openai_chat_url(&upstream_base_url, parts.uri.query())
};
let provider_request_body_value = if let Some(body) = payload.provider_request_body.clone() {
body
} else {
let Some(request_body_object) = body_json.as_object() else {
return Ok(None);
};
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
if let Some(mapped_model) = payload
.mapped_model
.as_ref()
.filter(|value| !value.trim().is_empty())
{
provider_request_body.insert(
"model".to_string(),
serde_json::Value::String(mapped_model.clone()),
);
}
provider_request_body.insert("stream".to_string(), serde_json::Value::Bool(true));
if let Some(prompt_cache_key) = payload
.prompt_cache_key
.as_ref()
.filter(|value| !value.trim().is_empty())
{
let existing = provider_request_body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(str::trim)
.unwrap_or_default();
if existing.is_empty() {
provider_request_body.insert(
"prompt_cache_key".to_string(),
serde_json::Value::String(prompt_cache_key.clone()),
);
}
}
serde_json::Value::Object(provider_request_body)
};
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
build_openai_passthrough_headers(
&parts.headers,
&auth_header,
&auth_value,
&payload.extra_headers,
payload.content_type.as_deref(),
)
} else {
payload.provider_request_headers.clone()
};
ensure_upstream_auth_header(&mut provider_request_headers, &auth_header, &auth_value);
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: true,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_openai_cli_stream_plan_from_decision(
parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
compact: bool,
) -> Result<Option<LocalStreamPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let url = if let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
{
upstream_url
} else {
let Some(upstream_base_url) = payload
.upstream_base_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
build_openai_cli_url(&upstream_base_url, parts.uri.query(), compact)
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let envelope_name = payload
.report_context
.as_ref()
.and_then(|context| context.get("envelope_name"))
.and_then(serde_json::Value::as_str);
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if provider_adaptation_requires_eventstream_accept(envelope_name, provider_api_format.as_str())
{
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "application/vnd.amazon.eventstream".to_string());
} else {
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
}
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: true,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}

View File

@@ -0,0 +1,253 @@
use aether_contracts::RequestBody;
use super::augment_sync_report_context;
use super::*;
use crate::gateway::ai_pipeline::private_surfaces::provider_adaptation_requires_eventstream_accept;
use crate::gateway::provider_transport::ensure_upstream_auth_header;
pub(crate) fn build_standard_sync_plan_from_decision(
_parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
) -> Result<Option<LocalSyncPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if payload.upstream_is_stream {
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
}
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: payload.upstream_is_stream,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}
pub(crate) fn build_standard_stream_plan_from_decision(
_parts: &http::request::Parts,
_body_json: &serde_json::Value,
payload: GatewayControlSyncDecisionResponse,
_inject_stream_flag: bool,
) -> Result<Option<LocalStreamPlanAndReport>, GatewayError> {
if generic_decision_missing_exact_provider_request(&payload) {
return Ok(None);
}
let Some(request_id) = payload
.request_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_id) = payload
.provider_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(endpoint_id) = payload
.endpoint_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(key_id) = payload
.key_id
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_header = payload
.auth_header
.clone()
.filter(|value| !value.trim().is_empty());
let auth_value = payload
.auth_value
.clone()
.filter(|value| !value.trim().is_empty());
if auth_header.is_some() != auth_value.is_some() {
return Ok(None);
}
let Some(provider_api_format) = payload
.provider_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(client_api_format) = payload
.client_api_format
.clone()
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
};
let envelope_name = payload
.report_context
.as_ref()
.and_then(|context| context.get("envelope_name"))
.and_then(serde_json::Value::as_str);
let mut provider_request_headers = payload.provider_request_headers.clone();
if let (Some(auth_header), Some(auth_value)) = (auth_header.as_deref(), auth_value.as_deref()) {
ensure_upstream_auth_header(&mut provider_request_headers, auth_header, auth_value);
}
if provider_adaptation_requires_eventstream_accept(envelope_name, provider_api_format.as_str())
{
provider_request_headers
.entry("accept".to_string())
.or_insert_with(|| "application/vnd.amazon.eventstream".to_string());
} else {
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
}
let plan = ExecutionPlan {
request_id,
candidate_id: payload.candidate_id.clone(),
provider_name: payload.provider_name.clone(),
provider_id,
endpoint_id,
key_id,
method: "POST".to_string(),
url,
headers: std::mem::take(&mut provider_request_headers),
content_type: payload
.content_type
.clone()
.or_else(|| Some("application/json".to_string())),
content_encoding: None,
body: RequestBody::from_json(provider_request_body_value.clone()),
stream: true,
client_api_format,
provider_api_format,
model_name: payload.model_name.clone(),
proxy: payload.proxy.clone(),
tls_profile: payload.tls_profile.clone(),
timeouts: payload.timeouts.clone(),
};
let report_context = augment_sync_report_context(
payload.report_context,
&plan.headers,
&provider_request_body_value,
)?;
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
report_context,
}))
}

View File

@@ -0,0 +1,530 @@
use std::collections::BTreeMap;
use base64::Engine as _;
use serde_json::Value;
use crate::gateway::ai_pipeline::private_surfaces::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
};
use crate::gateway::ai_pipeline::runtime::{KiroToClaudeCliStreamState, KIRO_ENVELOPE_NAME};
use crate::gateway::{GatewayError, GatewaySyncReportRequest};
enum ProviderPrivateStreamNormalizeMode {
EnvelopeUnwrap,
KiroToClaudeCli(KiroToClaudeCliStreamState),
}
pub(crate) struct ProviderPrivateStreamNormalizer {
report_context: Value,
buffered: Vec<u8>,
mode: ProviderPrivateStreamNormalizeMode,
}
pub(crate) fn provider_private_response_allows_sync_finalize(report_context: &Value) -> bool {
let has_envelope = report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false);
if !has_envelope {
return true;
}
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
provider_adaptation_allows_sync_finalize_envelope(envelope_name, provider_api_format)
|| matches!(envelope_name, "claude:cli")
}
pub(crate) fn normalize_provider_private_report_context(
report_context: Option<&Value>,
) -> Option<Value> {
let report_context = report_context?;
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Some(report_context.clone());
}
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
if provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format).is_none() {
return Some(report_context.clone());
}
Some(clear_private_envelope_context(report_context))
}
pub(crate) fn maybe_build_provider_private_stream_normalizer(
report_context: Option<&Value>,
) -> Option<ProviderPrivateStreamNormalizer> {
let report_context = report_context?;
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return None;
}
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
let descriptor =
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)?;
let mode = if descriptor
.envelope_name
.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME)
{
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(KiroToClaudeCliStreamState::new(
report_context,
))
} else if descriptor.unwraps_response_envelope {
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap
} else {
return None;
};
Some(ProviderPrivateStreamNormalizer {
report_context: report_context.clone(),
buffered: Vec::new(),
mode,
})
}
pub(crate) fn normalize_provider_private_response_value(
data: Value,
report_context: &Value,
) -> Result<Option<Value>, GatewayError> {
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(Some(data));
}
let mut unwrapped = match report_context.get("envelope_name").and_then(Value::as_str) {
Some("claude:cli") | Some(KIRO_ENVELOPE_NAME) => data,
Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME) => {
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
Value::Object(response.clone())
} else {
data
}
}
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME) => {
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
let mut unwrapped = response.clone();
if let Some(response_id) = data.get("responseId").cloned() {
unwrapped.insert("_v1internal_response_id".to_string(), response_id);
}
Value::Object(unwrapped)
} else {
data
}
}
_ => return Ok(None),
};
postprocess_private_response_value(&mut unwrapped, report_context);
Ok(Some(unwrapped))
}
pub(crate) fn maybe_normalize_provider_private_sync_report_payload(
payload: &GatewaySyncReportRequest,
) -> Result<Option<GatewaySyncReportRequest>, GatewayError> {
let Some(report_context) = payload.report_context.as_ref() else {
return Ok(Some(payload.clone()));
};
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(Some(payload.clone()));
}
if !provider_private_response_allows_sync_finalize(report_context) {
return Ok(None);
}
let mut normalized = payload.clone();
normalized.report_context = normalize_provider_private_report_context(Some(report_context));
if let Some(body_json) = payload.body_json.clone() {
normalized.body_json =
normalize_provider_private_response_value(body_json, report_context)?;
if normalized.body_json.is_none() {
return Ok(None);
}
}
if let Some(body_base64) = payload.body_base64.as_deref() {
let body_bytes = base64::engine::general_purpose::STANDARD
.decode(body_base64)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(normalized_bytes) =
normalize_provider_private_stream_bytes(report_context, &body_bytes)?
else {
return Ok(None);
};
if stream_body_contains_error_event(&normalized_bytes) {
return Ok(None);
}
normalized.body_base64 = (!normalized_bytes.is_empty())
.then(|| base64::engine::general_purpose::STANDARD.encode(normalized_bytes));
}
Ok(Some(normalized))
}
pub(crate) fn transform_provider_private_stream_line(
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
let Ok(text) = std::str::from_utf8(&line) else {
return Ok(line);
};
let trimmed = text.trim_matches('\r').trim();
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
return Ok(Vec::new());
}
let Some(data_line) = trimmed.strip_prefix("data:") else {
return Ok(line);
};
let data_line = data_line.trim();
if data_line.is_empty() || data_line == "[DONE]" {
return Ok(line);
}
let body: Value = match serde_json::from_str(data_line) {
Ok(value) => value,
Err(_) => return Ok(line),
};
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
return Ok(line);
}
let unwrapped = match envelope_name {
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME => body.get("response").cloned().unwrap_or(body),
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME => {
let mut response = body.get("response").cloned().unwrap_or(body.clone());
if let Some(response_id) = body.get("responseId").cloned() {
if let Some(object) = response.as_object_mut() {
object
.entry("_v1internal_response_id".to_string())
.or_insert(response_id);
}
}
inject_antigravity_stream_tool_ids(&mut response);
response
}
_ => body,
};
let mut out = b"data: ".to_vec();
out.extend(
serde_json::to_vec(&unwrapped).map_err(|err| GatewayError::Internal(err.to_string()))?,
);
out.extend_from_slice(b"\n\n");
Ok(out)
}
impl ProviderPrivateStreamNormalizer {
pub(crate) fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, GatewayError> {
match &mut self.mode {
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(state) => {
state.push_chunk(&self.report_context, chunk)
}
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
output.extend(transform_provider_private_stream_line(
&self.report_context,
line,
)?);
}
Ok(output)
}
}
}
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
match &mut self.mode {
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(state) => {
state.finish(&self.report_context)
}
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
if self.buffered.is_empty() {
return Ok(Vec::new());
}
let line = std::mem::take(&mut self.buffered);
transform_provider_private_stream_line(&self.report_context, line)
}
}
}
}
fn clear_private_envelope_context(report_context: &Value) -> Value {
let mut normalized = report_context.clone();
if let Some(object) = normalized.as_object_mut() {
object.insert("has_envelope".to_string(), Value::Bool(false));
object.remove("envelope_name");
}
normalized
}
fn normalize_provider_private_stream_bytes(
report_context: &Value,
body: &[u8],
) -> Result<Option<Vec<u8>>, GatewayError> {
let Some(mut normalizer) = maybe_build_provider_private_stream_normalizer(Some(report_context))
else {
return Ok(Some(body.to_vec()));
};
let mut normalized = normalizer.push_chunk(body)?;
normalized.extend(normalizer.finish()?);
Ok(Some(normalized))
}
fn local_finalize_response_model(report_context: &Value) -> &str {
report_context
.get("mapped_model")
.and_then(Value::as_str)
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or_default()
}
fn inject_antigravity_stream_tool_ids(value: &mut Value) {
let Some(candidates) = value.get_mut("candidates").and_then(Value::as_array_mut) else {
return;
};
for candidate in candidates {
let Some(parts) = candidate
.get_mut("content")
.and_then(Value::as_object_mut)
.and_then(|content| content.get_mut("parts"))
.and_then(Value::as_array_mut)
else {
continue;
};
let mut counters: BTreeMap<String, usize> = BTreeMap::new();
for part in parts {
let Some(function_call) = part.get_mut("functionCall").and_then(Value::as_object_mut)
else {
continue;
};
let has_id = function_call
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_id {
continue;
}
let name = function_call
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string();
let index = counters.entry(name.clone()).or_insert(0);
function_call.insert(
"id".to_string(),
Value::String(format!("call_{name}_{index}")),
);
*index += 1;
}
}
}
fn inject_antigravity_sync_tool_ids(response: &mut Value, model: &str) {
if !model.to_ascii_lowercase().contains("claude") {
return;
}
let Some(candidates) = response.get_mut("candidates").and_then(Value::as_array_mut) else {
return;
};
for candidate in candidates {
let Some(parts) = candidate
.get_mut("content")
.and_then(Value::as_object_mut)
.and_then(|content| content.get_mut("parts"))
.and_then(Value::as_array_mut)
else {
continue;
};
let mut name_counters: BTreeMap<String, usize> = BTreeMap::new();
for part in parts {
let function_call = if let Some(function_call) =
part.get_mut("functionCall").and_then(Value::as_object_mut)
{
function_call
} else if let Some(function_call) =
part.get_mut("function_call").and_then(Value::as_object_mut)
{
function_call
} else {
continue;
};
let has_id = function_call
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_id {
continue;
}
let function_name = function_call
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string();
let count = name_counters.entry(function_name.clone()).or_insert(0);
function_call.insert(
"id".to_string(),
Value::String(format!("call_{function_name}_{count}")),
);
*count += 1;
}
}
}
fn postprocess_private_response_value(data: &mut Value, report_context: &Value) {
if !matches!(
report_context.get("envelope_name").and_then(Value::as_str),
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME)
) {
return;
}
if let Some(object) = data.as_object_mut() {
if !object.contains_key("_v1internal_response_id") {
if let Some(response_id) = object.remove("responseId") {
object.insert("_v1internal_response_id".to_string(), response_id);
}
}
}
inject_antigravity_sync_tool_ids(data, local_finalize_response_model(report_context));
}
fn stream_body_contains_error_event(body: &[u8]) -> bool {
let Ok(text) = std::str::from_utf8(body) else {
return false;
};
let mut current_event_type: Option<String> = None;
for raw_line in text.lines() {
let line = raw_line.trim_matches('\r').trim();
if line.is_empty() || line.starts_with(':') {
continue;
}
if let Some(event_name) = line.strip_prefix("event:") {
current_event_type = Some(event_name.trim().to_string());
continue;
}
let data_line = if let Some(rest) = line.strip_prefix("data:") {
rest.trim()
} else {
line
};
if data_line.is_empty() || data_line == "[DONE]" {
continue;
}
let Ok(mut event) = serde_json::from_str::<Value>(data_line) else {
continue;
};
if let Some(event_object) = event.as_object_mut() {
if !event_object.contains_key("type") {
if let Some(event_name) = current_event_type.take() {
event_object.insert("type".to_string(), Value::String(event_name));
}
}
}
if event
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
{
return true;
}
current_event_type = None;
}
false
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
};
#[test]
fn normalizes_supported_private_report_context() {
let report_context = json!({
"has_envelope": true,
"envelope_name": "antigravity:v1internal",
"provider_api_format": "gemini:cli",
});
let normalized = normalize_provider_private_report_context(Some(&report_context))
.expect("context should normalize");
assert_eq!(normalized["has_envelope"], json!(false));
assert!(normalized.get("envelope_name").is_none());
}
#[test]
fn private_stream_normalizer_unwraps_antigravity_stream() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:cli",
"client_api_format": "gemini:cli",
"envelope_name": "antigravity:v1internal",
"mapped_model": "claude-sonnet-4-5",
});
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
.expect("normalizer should exist");
let output = normalizer
.push_chunk(
b"data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_123\"}\n\n",
)
.expect("unwrap should succeed");
let output_text = String::from_utf8(output).expect("text should decode");
assert!(output_text.contains("\"_v1internal_response_id\":\"resp_123\""));
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
}
}

Some files were not shown because too many files have changed in this diff Show More