feat(gateway): 重构 usage 数据层、迁移系统与系统导入

数据库迁移:
- 引入 baseline v2 bootstrap,空库首次启动自动初始化
- 服务启动不再自动执行迁移,需显式 `--migrate` 运行
- 新增 pending migration 检测,schema 落后时拒绝启动

Usage 数据层:
- usage body 存储外部化为独立 blob 表
- 新增 HTTP audit 表拆分存储请求/响应头与 body ref
- 后台清理任务支持 legacy body ref 元数据迁移
- usage runtime 写入迁移到专用 tokio runtime(独立线程池, 8MB 栈)

系统导入/导出:
- 支持用户、API Keys、钱包数据的完整导入
- 兼容 legacy 与 v1.3+ 两种导出格式

其他改进:
- executor outcome 增加 runtime miss 诊断上下文
- 主 tokio runtime 栈大小调整为 8MB
- 前端 provider 管理支持 base URL 配置
- dev.sh 支持 --migrate 参数
This commit is contained in:
fawney19
2026-04-13 14:01:22 +08:00
parent 3698e5a833
commit 5bb08e6aa4
106 changed files with 21730 additions and 1523 deletions
@@ -8,6 +8,7 @@ pub(crate) mod transport;
use axum::body::Body;
use axum::http::{Response, Uri};
use serde_json::Value;
use crate::{usage::GatewaySyncReportRequest, AppState, GatewayError};
@@ -58,6 +59,10 @@ pub(crate) fn collect_control_headers(
crate::headers::collect_control_headers(headers)
}
pub(crate) fn build_report_context_original_request_echo(body_json: &Value) -> Option<Value> {
(!body_json.is_null()).then(|| body_json.clone())
}
pub(crate) fn is_json_request(headers: &http::HeaderMap) -> bool {
crate::headers::is_json_request(headers)
}
@@ -99,3 +104,26 @@ pub(crate) fn maybe_build_local_sync_finalize_response(
) -> Result<Option<Response<Body>>, GatewayError> {
crate::execution_runtime::maybe_build_local_sync_finalize_response(trace_id, decision, payload)
}
#[cfg(test)]
mod tests {
use super::build_report_context_original_request_echo;
use serde_json::json;
#[test]
fn build_report_context_original_request_echo_preserves_full_request_body() {
let body = json!({
"messages": [{"role": "user", "content": "large payload should be omitted"}],
"service_tier": "default",
"instructions": "Be concise.",
"thinking": {"type": "enabled", "budget_tokens": 512},
"metadata": {"trace": "keep"},
"body_bytes_b64": "aGVsbG8=",
});
let echo =
build_report_context_original_request_echo(&body).expect("echo should be produced");
assert_eq!(echo, body);
}
}
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use tracing::debug;
use url::Url;
use crate::ai_pipeline::planner::common::{
@@ -155,6 +156,25 @@ async fn maybe_build_local_video_task_follow_up_sync_decision_payload(
} else {
ConversionMode::Bidirectional
};
let upstream_base_url = infer_upstream_base_url(&follow_up.plan.url);
debug!(
event_name = "local_video_follow_up_sync_decision_payload_built",
log_type = "debug",
trace_id = %trace_id,
request_id = %trace_id,
candidate_id = ?follow_up.plan.candidate_id,
provider_id = %follow_up.plan.provider_id,
endpoint_id = %follow_up.plan.endpoint_id,
key_id = %follow_up.plan.key_id,
plan_kind,
downstream_path = %parts.uri.path(),
provider_api_format = %follow_up.plan.provider_api_format,
client_api_format = %follow_up.plan.client_api_format,
upstream_base_url = ?upstream_base_url,
upstream_url = %follow_up.plan.url,
"gateway built local video follow-up sync decision payload"
);
Ok(Some(GatewayControlSyncDecisionResponse {
action: EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string(),
@@ -167,7 +187,7 @@ async fn maybe_build_local_video_task_follow_up_sync_decision_payload(
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_base_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()),
@@ -219,5 +239,79 @@ fn infer_upstream_base_url(upstream_url: &str) -> Option<String> {
base.push(':');
base.push_str(port.to_string().as_str());
}
let base_path = infer_upstream_base_path(parsed.path());
if !base_path.is_empty() {
base.push_str(base_path);
}
Some(base)
}
fn infer_upstream_base_path(path: &str) -> &str {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() || trimmed == "/" {
return "";
}
for suffix in [
"/responses/compact",
"/responses",
"/chat/completions",
"/messages",
] {
if let Some(prefix) = trimmed.strip_suffix(suffix) {
return normalize_inferred_base_path(prefix);
}
}
for marker in ["/v1/videos", "/v1beta/"] {
if let Some((prefix, _)) = trimmed.split_once(marker) {
return normalize_inferred_base_path(prefix);
}
}
normalize_inferred_base_path(trimmed)
}
fn normalize_inferred_base_path(path: &str) -> &str {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() || trimmed == "/" {
""
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::infer_upstream_base_url;
#[test]
fn infer_upstream_base_url_preserves_codex_base_path() {
assert_eq!(
infer_upstream_base_url("https://tiger.bookapi.cc/codex/responses").as_deref(),
Some("https://tiger.bookapi.cc/codex")
);
assert_eq!(
infer_upstream_base_url("https://chatgpt.com/backend-api/codex/responses").as_deref(),
Some("https://chatgpt.com/backend-api/codex")
);
}
#[test]
fn infer_upstream_base_url_preserves_nested_v1_prefix() {
assert_eq!(
infer_upstream_base_url("https://api.openai.example/custom/v1/chat/completions?mode=1")
.as_deref(),
Some("https://api.openai.example/custom/v1")
);
}
#[test]
fn infer_upstream_base_url_strips_video_operation_path() {
assert_eq!(
infer_upstream_base_url("https://video.example/nested/v1/videos/task-123/content")
.as_deref(),
Some("https://video.example/nested")
);
}
}
@@ -1,5 +1,7 @@
use crate::ai_pipeline::GatewayControlDecision;
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
use crate::{
AppState, GatewayControlSyncDecisionResponse, GatewayError, LocalExecutionRuntimeMissDiagnostic,
};
use super::super::plans::{resolve_stream_spec, resolve_sync_spec};
use super::candidates::{
@@ -8,6 +10,52 @@ use super::candidates::{
};
use super::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
fn extract_requested_model(
parts: &http::request::Parts,
body_json: &serde_json::Value,
spec: crate::ai_pipeline::LocalSameFormatProviderSpec,
) -> Option<String> {
match spec.family {
crate::ai_pipeline::LocalSameFormatProviderFamily::Standard => body_json
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
crate::ai_pipeline::LocalSameFormatProviderFamily::Gemini => {
let marker = "/models/";
let start = parts.uri.path().find(marker)? + marker.len();
let tail = &parts.uri.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())
}
}
}
}
fn build_local_same_format_miss_diagnostic(
decision: &GatewayControlDecision,
spec: crate::ai_pipeline::LocalSameFormatProviderSpec,
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(spec.decision_kind.to_string()),
requested_model: requested_model.map(ToOwned::to_owned),
candidate_count: None,
skipped_candidate_count: None,
skip_reasons: std::collections::BTreeMap::new(),
}
}
pub(crate) async fn maybe_build_sync_local_same_format_provider_decision_payload(
state: &AppState,
parts: &http::request::Parts,
@@ -25,12 +73,42 @@ pub(crate) async fn maybe_build_sync_local_same_format_provider_decision_payload
)
.await
else {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
extract_requested_model(parts, body_json, spec).as_deref(),
"decision_input_unavailable",
),
);
return Ok(None);
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let preserve_existing_candidate_signal = candidate_count == 0
&& state.local_execution_runtime_miss_diagnostic_has_candidate_signal(trace_id);
if !preserve_existing_candidate_signal {
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
}
for attempt in attempts {
if let Some(payload) =
@@ -43,6 +121,18 @@ pub(crate) async fn maybe_build_sync_local_same_format_provider_decision_payload
}
}
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 {
"candidate_list_empty".to_string()
} else if skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string()
} else {
"no_local_sync_plans".to_string()
};
});
Ok(None)
}
@@ -63,12 +153,42 @@ pub(crate) async fn maybe_build_stream_local_same_format_provider_decision_paylo
)
.await
else {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
extract_requested_model(parts, body_json, spec).as_deref(),
"decision_input_unavailable",
),
);
return Ok(None);
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let preserve_existing_candidate_signal = candidate_count == 0
&& state.local_execution_runtime_miss_diagnostic_has_candidate_signal(trace_id);
if !preserve_existing_candidate_signal {
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
}
for attempt in attempts {
if let Some(payload) =
@@ -81,5 +201,17 @@ pub(crate) async fn maybe_build_stream_local_same_format_provider_decision_paylo
}
}
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 {
"candidate_list_empty".to_string()
} else if skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string()
} else {
"no_local_stream_plans".to_string()
};
});
Ok(None)
}
@@ -85,7 +85,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
trace_id: &str,
input: &LocalSameFormatProviderDecisionInput,
spec: LocalSameFormatProviderSpec,
) -> Result<Vec<LocalSameFormatProviderCandidateAttempt>, GatewayError> {
) -> Result<(Vec<LocalSameFormatProviderCandidateAttempt>, usize), GatewayError> {
let planner_state = PlannerAppState::new(state);
let candidates = planner_state
.list_selectable_candidates(
@@ -104,6 +104,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
input.required_capabilities.as_ref(),
)
.await;
let candidate_count = candidates.len();
let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len());
@@ -160,5 +161,5 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
});
}
Ok(attempts)
Ok((attempts, candidate_count))
}
@@ -287,9 +287,8 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
"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,
"original_request_body": crate::ai_pipeline::build_report_context_original_request_echo(body_json),
"has_envelope": is_kiro || is_antigravity,
"envelope_name": if is_kiro {
Some(KIRO_ENVELOPE_NAME)
@@ -359,6 +358,13 @@ pub(super) async fn mark_skipped_local_same_format_provider_candidate(
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;
});
PlannerAppState::new(state)
.persist_skipped_local_candidate(
trace_id,
@@ -16,6 +16,53 @@ use crate::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::LocalExecutionRuntimeMissDiagnostic;
fn extract_requested_model(
parts: &http::request::Parts,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Option<String> {
match spec.family {
LocalSameFormatProviderFamily::Standard => body_json
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
LocalSameFormatProviderFamily::Gemini => {
let marker = "/models/";
let start = parts.uri.path().find(marker)? + marker.len();
let tail = &parts.uri.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())
}
}
}
}
fn build_local_same_format_miss_diagnostic(
decision: &GatewayControlDecision,
spec: LocalSameFormatProviderSpec,
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(spec.decision_kind.to_string()),
requested_model: requested_model.map(ToOwned::to_owned),
candidate_count: None,
skipped_candidate_count: None,
skip_reasons: std::collections::BTreeMap::new(),
}
}
pub(crate) async fn build_local_sync_plan_and_reports(
state: &AppState,
@@ -30,12 +77,44 @@ pub(crate) async fn build_local_sync_plan_and_reports(
)
.await
else {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
extract_requested_model(parts, body_json, spec).as_deref(),
"decision_input_unavailable",
),
);
return Ok(Vec::new());
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let preserve_existing_candidate_signal = candidate_count == 0
&& state.local_execution_runtime_miss_diagnostic_has_candidate_signal(trace_id);
if !preserve_existing_candidate_signal {
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
}
if candidate_count == 0 {
return Ok(Vec::new());
}
let mut plans = Vec::new();
for attempt in attempts {
@@ -70,6 +149,16 @@ pub(crate) async fn build_local_sync_plan_and_reports(
}
}
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)
}
@@ -86,12 +175,44 @@ pub(crate) async fn build_local_stream_plan_and_reports(
)
.await
else {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
extract_requested_model(parts, body_json, spec).as_deref(),
"decision_input_unavailable",
),
);
return Ok(Vec::new());
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_same_format_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_same_format_provider_candidate_attempts(state, trace_id, &input, spec)
.await?;
let preserve_existing_candidate_signal = candidate_count == 0
&& state.local_execution_runtime_miss_diagnostic_has_candidate_signal(trace_id);
if !preserve_existing_candidate_signal {
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
}
if candidate_count == 0 {
return Ok(Vec::new());
}
let mut plans = Vec::new();
for attempt in attempts {
@@ -126,5 +247,15 @@ pub(crate) async fn build_local_stream_plan_and_reports(
}
}
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)
}
@@ -297,7 +297,7 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
"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,
"original_request_body": crate::ai_pipeline::build_report_context_original_request_echo(&original_request_body),
"has_envelope": false,
"needs_conversion": false,
})),
@@ -263,7 +263,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
"client_api_format": spec.api_format,
"mapped_model": mapped_model,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"original_request_body": crate::ai_pipeline::build_report_context_original_request_echo(body_json),
"has_envelope": false,
"needs_conversion": false,
})),
@@ -6,7 +6,9 @@ use crate::ai_pipeline::planner::plan_builders::{
LocalStreamPlanAndReport, LocalSyncPlanAndReport,
};
use crate::ai_pipeline::GatewayControlDecision;
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
use crate::{
AppState, GatewayControlSyncDecisionResponse, GatewayError, LocalExecutionRuntimeMissDiagnostic,
};
use super::candidates::{
materialize_local_standard_candidate_attempts, resolve_local_standard_decision_input,
@@ -14,6 +16,52 @@ use super::candidates::{
use super::payload::maybe_build_local_standard_decision_payload_for_candidate;
use super::{LocalStandardSourceFamily, LocalStandardSpec};
fn extract_requested_model(
parts: &http::request::Parts,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Option<String> {
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 => {
let marker = "/models/";
let start = parts.uri.path().find(marker)? + marker.len();
let tail = &parts.uri.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())
}
}
}
}
fn build_local_standard_miss_diagnostic(
decision: &GatewayControlDecision,
spec: LocalStandardSpec,
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(spec.decision_kind.to_string()),
requested_model: requested_model.map(ToOwned::to_owned),
candidate_count: None,
skipped_candidate_count: None,
skip_reasons: std::collections::BTreeMap::new(),
}
}
pub(crate) async fn maybe_build_sync_via_standard_family_payload(
state: &AppState,
parts: &http::request::Parts,
@@ -34,8 +82,25 @@ pub(crate) async fn maybe_build_sync_via_standard_family_payload(
return Ok(None);
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_standard_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
for attempt in attempts {
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
@@ -47,6 +112,18 @@ pub(crate) async fn maybe_build_sync_via_standard_family_payload(
}
}
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 {
"candidate_list_empty".to_string()
} else if skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string()
} else {
"no_local_sync_plans".to_string()
};
});
Ok(None)
}
@@ -70,8 +147,25 @@ pub(crate) async fn maybe_build_stream_via_standard_family_payload(
return Ok(None);
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_standard_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
for attempt in attempts {
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
@@ -83,6 +177,18 @@ pub(crate) async fn maybe_build_stream_via_standard_family_payload(
}
}
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 {
"candidate_list_empty".to_string()
} else if skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string()
} else {
"no_local_stream_plans".to_string()
};
});
Ok(None)
}
@@ -98,10 +204,39 @@ pub(crate) async fn build_local_sync_plan_and_reports(
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_standard_miss_diagnostic(
decision,
spec,
extract_requested_model(parts, body_json, spec).as_deref(),
"decision_input_unavailable",
),
);
return Ok(Vec::new());
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_standard_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
if candidate_count == 0 {
return Ok(Vec::new());
}
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
@@ -132,6 +267,15 @@ pub(crate) async fn build_local_sync_plan_and_reports(
}
}
}
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)
}
@@ -147,10 +291,39 @@ pub(crate) async fn build_local_stream_plan_and_reports(
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_standard_miss_diagnostic(
decision,
spec,
extract_requested_model(parts, body_json, spec).as_deref(),
"decision_input_unavailable",
),
);
return Ok(Vec::new());
};
let attempts =
state.set_local_execution_runtime_miss_diagnostic(
trace_id,
build_local_standard_miss_diagnostic(
decision,
spec,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
),
);
let (attempts, candidate_count) =
materialize_local_standard_candidate_attempts(state, trace_id, &input, spec).await?;
state.mutate_local_execution_runtime_miss_diagnostic(trace_id, |diagnostic| {
diagnostic.candidate_count = Some(candidate_count);
diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
};
});
if candidate_count == 0 {
return Ok(Vec::new());
}
let mut plans = Vec::new();
for attempt in attempts {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
@@ -181,5 +354,14 @@ pub(crate) async fn build_local_stream_plan_and_reports(
}
}
}
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)
}
@@ -91,7 +91,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
trace_id: &str,
input: &LocalStandardDecisionInput,
spec: LocalStandardSpec,
) -> Result<Vec<LocalStandardCandidateAttempt>, GatewayError> {
) -> Result<(Vec<LocalStandardCandidateAttempt>, usize), GatewayError> {
let planner_state = PlannerAppState::new(state);
let mut seen_candidates = BTreeSet::new();
let mut candidates = Vec::new();
@@ -144,6 +144,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
input.required_capabilities.as_ref(),
)
.await;
let candidate_count = candidates.len();
let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len());
@@ -257,7 +258,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
});
}
Ok(attempts)
Ok((attempts, candidate_count))
}
fn auth_snapshot_allows_cross_format_candidate(
@@ -358,9 +358,8 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
"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,
"original_request_body": crate::ai_pipeline::build_report_context_original_request_echo(body_json),
"has_envelope": false,
"needs_conversion": true,
}),
@@ -384,6 +383,13 @@ pub(super) async fn mark_skipped_local_standard_candidate(
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;
});
PlannerAppState::new(state)
.persist_skipped_local_candidate(
trace_id,
@@ -1,6 +1,14 @@
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};
use http::Request;
use serde_json::{json, Value};
use super::{build_cross_format_openai_cli_request_body, build_local_openai_cli_request_body};
use super::{
build_cross_format_openai_cli_request_body, build_local_openai_cli_request_body,
build_local_openai_cli_upstream_url,
};
fn object_keys(value: &Value) -> Vec<&str> {
value
@@ -11,6 +19,59 @@ fn object_keys(value: &Value) -> Vec<&str> {
.collect()
}
fn sample_transport(base_url: &str, api_format: &str) -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-codex".to_string(),
name: "codex".to_string(),
provider_type: "codex".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: false,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-codex".to_string(),
provider_id: "provider-codex".to_string(),
api_format: api_format.to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
is_active: true,
base_url: base_url.to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-codex".to_string(),
provider_id: "provider-codex".to_string(),
name: "oauth".to_string(),
auth_type: "oauth".to_string(),
is_active: true,
api_formats: Some(vec![api_format.to_string()]),
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
decrypted_api_key: "__placeholder__".to_string(),
decrypted_auth_config: None,
},
}
}
#[test]
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
let body_json = json!({
@@ -103,6 +164,25 @@ fn local_openai_compact_wrapper_strips_store_for_same_format_requests() {
assert!(provider_request_body.get("store").is_none());
}
#[test]
fn local_openai_cli_upstream_url_preserves_codex_base_path() {
let request = Request::builder()
.method("POST")
.uri("/v1/responses")
.body(())
.expect("request should build");
let (parts, _) = request.into_parts();
let upstream_url = build_local_openai_cli_upstream_url(
&parts,
&sample_transport("https://tiger.bookapi.cc/codex", "openai:cli"),
false,
)
.expect("openai cli upstream url should build");
assert_eq!(upstream_url, "https://tiger.bookapi.cc/codex/responses");
}
#[test]
fn strips_metadata_for_codex_openai_cli_requests() {
let body_json = json!({
@@ -310,9 +310,8 @@ pub(super) async fn build_cross_format_local_openai_chat_decision_payload_for_ca
"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,
"original_request_body": crate::ai_pipeline::build_report_context_original_request_echo(body_json),
"has_envelope": false,
"needs_conversion": true,
}),
@@ -254,9 +254,8 @@ pub(super) async fn build_same_format_local_openai_chat_decision_payload_for_can
"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,
"original_request_body": crate::ai_pipeline::build_report_context_original_request_echo(body_json),
"has_envelope": false,
"needs_conversion": false,
}),
@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use serde_json::json;
use tracing::debug;
use crate::ai_pipeline::collect_control_headers;
use crate::ai_pipeline::transport::{
@@ -53,6 +54,31 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
let proxy =
resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &resolved.transport).await;
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
debug!(
event_name = "local_openai_cli_decision_payload_built",
log_type = "debug",
trace_id = %trace_id,
candidate_id = %candidate_id,
candidate_index,
provider_name = %resolved.transport.provider.name,
provider_id = %candidate.provider_id,
endpoint_id = %candidate.endpoint_id,
key_id = %candidate.key_id,
decision_kind = spec.decision_kind,
execution_strategy = resolved.execution_strategy.as_str(),
conversion_mode = resolved.conversion_mode.as_str(),
client_api_format = spec.api_format,
provider_api_format = %resolved.provider_api_format,
request_path = %parts.uri.path(),
request_query = ?parts.uri.query(),
upstream_base_url = %resolved.transport.endpoint.base_url,
upstream_url = %resolved.upstream_url,
upstream_is_stream = resolved.upstream_is_stream,
has_envelope = resolved.is_antigravity,
"gateway built local openai cli decision payload"
);
Some(GatewayControlSyncDecisionResponse {
action: if spec.require_streaming {
@@ -89,7 +115,7 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
content_type: Some("application/json".to_string()),
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(&resolved.transport),
timeouts,
upstream_is_stream: resolved.upstream_is_stream,
report_kind: Some(spec.report_kind.to_string()),
report_context: Some(append_local_failover_policy_to_value(
@@ -115,9 +141,8 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
"upstream_url": resolved.upstream_url,
"provider_request_method": serde_json::Value::Null,
"provider_request_headers": resolved.provider_request_headers,
"provider_request_body": resolved.provider_request_body,
"original_headers": collect_control_headers(&parts.headers),
"original_request_body": body_json,
"original_request_body": crate::ai_pipeline::build_report_context_original_request_echo(body_json),
"has_envelope": resolved.is_antigravity,
"envelope_name": if resolved.is_antigravity {
Some("antigravity:v1internal")
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use serde_json::Value;
use tracing::warn;
use tracing::{debug, warn};
use crate::ai_pipeline::conversion::{
request_conversion_direct_auth, request_conversion_kind,
@@ -430,6 +430,30 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
ConversionMode::None
};
debug!(
event_name = "local_openai_cli_upstream_url_resolved",
log_type = "debug",
trace_id = %trace_id,
candidate_id = %candidate_id,
candidate_index,
provider_id = %candidate.provider_id,
endpoint_id = %candidate.endpoint_id,
key_id = %candidate.key_id,
provider_type = %transport.provider.provider_type,
client_api_format = spec.api_format,
provider_api_format = %provider_api_format,
execution_strategy = execution_strategy.as_str(),
conversion_mode = conversion_mode.as_str(),
base_url = %transport.endpoint.base_url,
custom_path = ?transport.endpoint.custom_path,
request_path = %parts.uri.path(),
request_query = ?parts.uri.query(),
mapped_model = %mapped_model,
upstream_url = %upstream_url,
upstream_is_stream,
"gateway resolved local openai cli upstream url"
);
Some(LocalOpenAiCliCandidatePayloadParts {
auth_header,
auth_value,
@@ -1,4 +1,5 @@
use aether_contracts::{ExecutionPlan, RequestBody};
use tracing::debug;
use super::super::{
augment_sync_report_context, generic_decision_missing_exact_provider_request,
@@ -263,12 +264,12 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
else {
return Ok(None);
};
let url = if let Some(upstream_url) = payload
let (url, url_source) = if let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
{
upstream_url
(upstream_url, "upstream_url")
} else {
let Some(upstream_base_url) = payload
.upstream_base_url
@@ -277,7 +278,10 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
else {
return Ok(None);
};
build_openai_cli_url(&upstream_base_url, parts.uri.query(), compact)
(
build_openai_cli_url(&upstream_base_url, parts.uri.query(), compact),
"upstream_base_url",
)
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
@@ -330,6 +334,27 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
timeouts: payload.timeouts.clone(),
};
debug!(
event_name = "local_openai_cli_stream_plan_built",
log_type = "debug",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
provider_id = %plan.provider_id,
endpoint_id = %plan.endpoint_id,
key_id = %plan.key_id,
downstream_path = %parts.uri.path(),
downstream_query = ?parts.uri.query(),
url_source,
decision_upstream_base_url = ?payload.upstream_base_url,
decision_upstream_url = ?payload.upstream_url,
plan_url = %plan.url,
client_api_format = %plan.client_api_format,
provider_api_format = %plan.provider_api_format,
upstream_is_stream = payload.upstream_is_stream,
compact,
"gateway built local openai cli stream execution plan"
);
Ok(Some(LocalStreamPlanAndReport {
plan,
report_kind: payload.report_kind,
@@ -446,12 +471,14 @@ mod tests {
"prompt_cache_key",
]
);
let report_context = built
.report_context
.as_ref()
.and_then(|value| value.get("provider_request_body"))
.expect("report context should contain provider request body");
assert_eq!(object_keys(report_context), object_keys(plan_body));
assert!(
built
.report_context
.as_ref()
.and_then(|value| value.get("provider_request_body"))
.is_none(),
"report context should not duplicate provider request body"
);
}
#[test]
@@ -1,4 +1,5 @@
use aether_contracts::{ExecutionPlan, RequestBody};
use tracing::debug;
use super::super::{
augment_sync_report_context, generic_decision_missing_exact_provider_request,
@@ -267,12 +268,12 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
else {
return Ok(None);
};
let url = if let Some(upstream_url) = payload
let (url, url_source) = if let Some(upstream_url) = payload
.upstream_url
.clone()
.filter(|value| !value.trim().is_empty())
{
upstream_url
(upstream_url, "upstream_url")
} else {
let Some(upstream_base_url) = payload
.upstream_base_url
@@ -281,7 +282,10 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
else {
return Ok(None);
};
build_openai_cli_url(&upstream_base_url, parts.uri.query(), compact)
(
build_openai_cli_url(&upstream_base_url, parts.uri.query(), compact),
"upstream_base_url",
)
};
let Some(provider_request_body_value) = payload.provider_request_body.clone() else {
return Ok(None);
@@ -324,6 +328,27 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
timeouts: payload.timeouts.clone(),
};
debug!(
event_name = "local_openai_cli_sync_plan_built",
log_type = "debug",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
provider_id = %plan.provider_id,
endpoint_id = %plan.endpoint_id,
key_id = %plan.key_id,
downstream_path = %parts.uri.path(),
downstream_query = ?parts.uri.query(),
url_source,
decision_upstream_base_url = ?payload.upstream_base_url,
decision_upstream_url = ?payload.upstream_url,
plan_url = %plan.url,
client_api_format = %plan.client_api_format,
provider_api_format = %plan.provider_api_format,
upstream_is_stream = payload.upstream_is_stream,
compact,
"gateway built local openai cli sync execution plan"
);
Ok(Some(LocalSyncPlanAndReport {
plan,
report_kind: payload.report_kind,
@@ -440,12 +465,14 @@ mod tests {
"prompt_cache_key",
]
);
let report_context = built
.report_context
.as_ref()
.and_then(|value| value.get("provider_request_body"))
.expect("report context should contain provider request body");
assert_eq!(object_keys(report_context), object_keys(plan_body));
assert!(
built
.report_context
.as_ref()
.and_then(|value| value.get("provider_request_body"))
.is_none(),
"report context should not duplicate provider request body"
);
}
#[test]