mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-13 04:29:50 +08:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
+163
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
+143
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user