feat(codex): align Search and execution protocol

This commit is contained in:
MMEXA
2026-07-12 03:04:15 +08:00
parent 59d37ae1dd
commit 2316df5c9a
121 changed files with 5141 additions and 543 deletions
@@ -0,0 +1,301 @@
use std::time::Duration;
use tracing::warn;
use crate::admin_api::{reconcile_admin_fixed_provider_template_endpoints, AdminAppState};
use crate::task_runtime::{
spawn_fire_and_forget, task_definition, TASK_KEY_FIXED_PROVIDER_RECONCILIATION,
};
use crate::{AppState, GatewayError};
const FIXED_PROVIDER_RECONCILIATION_LOCK_KEY: &str =
"task_runtime:lock:maintenance.provider.fixed_template.reconcile";
const FIXED_PROVIDER_RECONCILIATION_LOCK_TTL: Duration = Duration::from_secs(10 * 60);
const FIXED_PROVIDER_RECONCILIATION_RETRY_DELAY: Duration = Duration::from_secs(2);
const RECONCILED_PROVIDER_TYPE: &str = "codex";
pub(crate) async fn perform_fixed_provider_reconciliation_once(
state: &AppState,
) -> Result<bool, GatewayError> {
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return Ok(false);
}
let Some(lock) = state
.runtime_state
.lock_try_acquire(
FIXED_PROVIDER_RECONCILIATION_LOCK_KEY,
state.tunnel.local_instance_id(),
FIXED_PROVIDER_RECONCILIATION_LOCK_TTL,
)
.await
.map_err(|error| GatewayError::Internal(error.to_string()))?
else {
return Ok(false);
};
let result = reconcile_fixed_provider_templates(state).await;
if let Err(error) = state.runtime_state.lock_release(&lock).await {
warn!(
event_name = "fixed_provider_reconciliation_lock_release_failed",
log_type = "ops",
error = ?error,
"gateway fixed provider reconciliation lock release failed"
);
}
result.map(|()| true)
}
async fn reconcile_fixed_provider_templates(state: &AppState) -> Result<(), GatewayError> {
let providers = state.list_provider_catalog_providers(false).await?;
let admin_state = AdminAppState::new(state);
let mut failures = Vec::new();
for provider in &providers {
if !provider
.provider_type
.trim()
.eq_ignore_ascii_case(RECONCILED_PROVIDER_TYPE)
|| admin_state
.fixed_provider_template(&provider.provider_type)
.is_none()
{
continue;
}
if let Err(error) =
reconcile_admin_fixed_provider_template_endpoints(&admin_state, provider).await
{
failures.push(format!(
"provider {} endpoint reconciliation failed: {error:?}",
provider.id,
));
continue;
}
}
if !failures.is_empty() {
return Err(GatewayError::Internal(failures.join("; ")));
}
Ok(())
}
async fn perform_fixed_provider_reconciliation_with_retry(state: &AppState) {
let max_attempts = task_definition(TASK_KEY_FIXED_PROVIDER_RECONCILIATION)
.map(|definition| definition.retry_policy.max_attempts)
.unwrap_or(1)
.max(1);
for attempt in 1..=max_attempts {
match perform_fixed_provider_reconciliation_once(state).await {
Ok(_) => return,
Err(error) if attempt < max_attempts => {
warn!(
event_name = "fixed_provider_reconciliation_retrying",
log_type = "ops",
attempt,
max_attempts,
error = ?error,
"gateway fixed provider reconciliation will retry"
);
tokio::time::sleep(FIXED_PROVIDER_RECONCILIATION_RETRY_DELAY).await;
}
Err(error) => {
warn!(
event_name = "fixed_provider_reconciliation_failed",
log_type = "ops",
attempt,
max_attempts,
error = ?error,
"gateway fixed provider reconciliation failed"
);
return;
}
}
}
}
pub(crate) fn spawn_fixed_provider_reconciliation_task(
state: AppState,
) -> Option<tokio::task::JoinHandle<()>> {
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return None;
}
Some(spawn_fire_and_forget(
TASK_KEY_FIXED_PROVIDER_RECONCILIATION,
async move {
perform_fixed_provider_reconciliation_with_retry(&state).await;
},
))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
};
use serde_json::json;
use super::{
perform_fixed_provider_reconciliation_once, FIXED_PROVIDER_RECONCILIATION_LOCK_KEY,
FIXED_PROVIDER_RECONCILIATION_LOCK_TTL,
};
use crate::data::GatewayDataState;
use crate::AppState;
#[tokio::test]
async fn fixed_provider_reconciliation_respects_runtime_singleton_lock() {
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![],
vec![],
vec![],
));
let state = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(repository),
);
let lock = state
.runtime_state
.lock_try_acquire(
FIXED_PROVIDER_RECONCILIATION_LOCK_KEY,
"another-gateway",
FIXED_PROVIDER_RECONCILIATION_LOCK_TTL,
)
.await
.expect("runtime lock should be available")
.expect("runtime lock should be acquired");
assert!(!perform_fixed_provider_reconciliation_once(&state)
.await
.expect("locked reconciliation should skip"));
assert!(state
.runtime_state
.lock_release(&lock)
.await
.expect("runtime lock should release"));
assert!(perform_fixed_provider_reconciliation_once(&state)
.await
.expect("unlocked reconciliation should run"));
}
#[tokio::test]
async fn fixed_provider_reconciliation_preserves_existing_endpoint_and_is_idempotent() {
let mut provider = StoredProviderCatalogProvider::new(
"provider-codex".to_string(),
"Codex".to_string(),
None,
"codex".to_string(),
)
.expect("provider should build");
provider.is_active = false;
provider.max_retries = Some(2);
let mut responses = StoredProviderCatalogEndpoint::new(
"endpoint-codex-responses".to_string(),
provider.id.clone(),
"openai:responses".to_string(),
Some("openai".to_string()),
Some("cli".to_string()),
false,
)
.expect("endpoint should build")
.with_transport_fields(
"http://127.0.0.1:18181/backend-api/codex".to_string(),
None,
None,
Some(9),
None,
Some(json!({"upstream_stream_policy": "force_non_stream"})),
None,
Some(json!({"url": "http://proxy.internal:8080"})),
)
.expect("endpoint transport should build");
responses.updated_at_unix_secs = Some(100);
let mut key = StoredProviderCatalogKey::new(
"key-codex".to_string(),
provider.id.clone(),
"oauth".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should build");
key.api_formats = Some(json!(["openai:responses"]));
let unrelated_fixed_provider = StoredProviderCatalogProvider::new(
"provider-claude-code".to_string(),
"Claude Code".to_string(),
None,
"claude_code".to_string(),
)
.expect("unrelated fixed provider should build");
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider, unrelated_fixed_provider],
vec![responses],
vec![key],
));
let state = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(repository.clone()),
);
assert!(perform_fixed_provider_reconciliation_once(&state)
.await
.expect("reconciliation should run"));
let first_endpoints = repository
.list_endpoints_by_provider_ids(&["provider-codex".to_string()])
.await
.expect("endpoints should list");
assert_eq!(first_endpoints.len(), 4);
let responses = first_endpoints
.iter()
.find(|endpoint| endpoint.api_format == "openai:responses")
.expect("responses endpoint should exist");
assert_eq!(
responses.base_url,
"http://127.0.0.1:18181/backend-api/codex"
);
assert!(!responses.is_active);
assert_eq!(responses.max_retries, Some(9));
assert_eq!(
responses.proxy,
Some(json!({"url": "http://proxy.internal:8080"}))
);
assert_eq!(
responses
.config
.as_ref()
.and_then(|value| value.get("upstream_stream_policy")),
Some(&json!("force_non_stream"))
);
assert!(first_endpoints
.iter()
.any(|endpoint| endpoint.api_format == "openai:search"));
let keys = repository
.list_keys_by_provider_ids(&["provider-codex".to_string()])
.await
.expect("keys should list");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].api_formats, Some(json!(["openai:responses"])));
assert!(repository
.list_endpoints_by_provider_ids(&["provider-claude-code".to_string()])
.await
.expect("unrelated endpoints should list")
.is_empty());
assert!(perform_fixed_provider_reconciliation_once(&state)
.await
.expect("second reconciliation should run"));
let second_endpoints = repository
.list_endpoints_by_provider_ids(&["provider-codex".to_string()])
.await
.expect("endpoints should list again");
assert_eq!(second_endpoints, first_endpoints);
}
}
@@ -21,7 +21,8 @@ use super::{
pending_cleanup_timeout_minutes, plan_pending_cleanup_batch, provider_checkin_schedule,
proxy_node_metrics_cleanup_settings, record_proxy_upgrade_traffic_success,
run_db_maintenance_with, run_proxy_upgrade_rollout_once, spawn_account_self_check_worker,
spawn_audit_cleanup_worker, spawn_db_maintenance_worker, spawn_oauth_token_refresh_worker,
spawn_audit_cleanup_worker, spawn_db_maintenance_worker,
spawn_fixed_provider_reconciliation_task, spawn_oauth_token_refresh_worker,
spawn_pending_cleanup_worker, spawn_pool_monitor_worker, spawn_pool_quota_probe_worker,
spawn_provider_checkin_worker, spawn_proxy_node_stale_cleanup_worker,
spawn_proxy_upgrade_rollout_worker, spawn_stats_aggregation_worker,
@@ -74,6 +75,14 @@ async fn spawn_oauth_token_refresh_worker_skips_when_provider_catalog_unavailabl
assert!(spawn_oauth_token_refresh_worker(state).is_none());
}
#[tokio::test]
async fn spawn_fixed_provider_reconciliation_task_skips_when_provider_catalog_unavailable() {
let state = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(GatewayDataState::disabled());
assert!(spawn_fixed_provider_reconciliation_task(state).is_none());
}
#[tokio::test]
async fn spawn_proxy_upgrade_rollout_worker_skips_when_system_config_unavailable() {
let repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![]));