mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
1976 lines
75 KiB
Rust
1976 lines
75 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
|
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
|
use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
|
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
|
use aether_data::repository::quota::InMemoryProviderQuotaRepository;
|
|
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
|
use aether_data_contracts::repository::global_models::{
|
|
GlobalModelReadRepository, StoredAdminGlobalModel, StoredAdminProviderModel,
|
|
StoredProviderActiveGlobalModel, StoredProviderModelStats, StoredPublicGlobalModel,
|
|
};
|
|
use aether_data_contracts::repository::provider_catalog::{
|
|
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
|
StoredProviderCatalogProvider,
|
|
};
|
|
use axum::body::{Body, Bytes};
|
|
use axum::routing::any;
|
|
use axum::{extract::Request, Router};
|
|
use http::{HeaderMap, HeaderValue, StatusCode};
|
|
use serde_json::json;
|
|
|
|
use super::super::{
|
|
build_router_with_state, issue_test_admin_access_token, sample_admin_provider_model,
|
|
sample_endpoint, sample_key, sample_provider, sample_provider_active_global_model,
|
|
sample_provider_model_stats, sample_provider_quota, sample_public_global_model_with_mappings,
|
|
sample_request_candidate, start_server, AppState,
|
|
};
|
|
use crate::admin_api::{
|
|
maybe_build_local_admin_providers_response, AdminAppState, AdminRequestContext,
|
|
};
|
|
use crate::audit::AdminAuditEvent;
|
|
use crate::constants::{
|
|
GATEWAY_HEADER, TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER,
|
|
TRUSTED_ADMIN_USER_ID_HEADER, TRUSTED_ADMIN_USER_ROLE_HEADER,
|
|
};
|
|
use crate::control::resolve_public_request_context;
|
|
use crate::data::GatewayDataState;
|
|
|
|
const ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin provider catalog data unavailable";
|
|
|
|
fn trusted_admin_headers() -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(GATEWAY_HEADER, HeaderValue::from_static("rust-phase3b"));
|
|
headers.insert(
|
|
TRUSTED_ADMIN_USER_ID_HEADER,
|
|
HeaderValue::from_static("admin-user-123"),
|
|
);
|
|
headers.insert(
|
|
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
|
HeaderValue::from_static("admin"),
|
|
);
|
|
headers.insert(
|
|
TRUSTED_ADMIN_SESSION_ID_HEADER,
|
|
HeaderValue::from_static("session-123"),
|
|
);
|
|
headers.insert(
|
|
TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER,
|
|
HeaderValue::from_static("management-token-123"),
|
|
);
|
|
headers
|
|
}
|
|
|
|
async fn local_admin_providers_response(
|
|
state: &AppState,
|
|
method: http::Method,
|
|
uri: &str,
|
|
body: Option<serde_json::Value>,
|
|
) -> axum::response::Response<Body> {
|
|
let headers = trusted_admin_headers();
|
|
let request_context = resolve_public_request_context(
|
|
state,
|
|
&method,
|
|
&uri.parse().expect("uri should parse"),
|
|
&headers,
|
|
"trace-123",
|
|
)
|
|
.await
|
|
.expect("request context should resolve");
|
|
let body_bytes = body.map(|value| Bytes::from(value.to_string()));
|
|
maybe_build_local_admin_providers_response(
|
|
&AdminAppState::new(state),
|
|
&AdminRequestContext::new(&request_context),
|
|
body_bytes.as_ref(),
|
|
)
|
|
.await
|
|
.expect("local providers response should build")
|
|
.expect("providers route should resolve locally")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_providers_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10)
|
|
.with_timestamps(Some(1_711_000_000), Some(1_711_000_100)),
|
|
sample_provider("provider-anthropic", "anthropic", 20)
|
|
.with_timestamps(Some(1_710_000_000), Some(1_710_000_100)),
|
|
],
|
|
vec![sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
)],
|
|
vec![sample_key(
|
|
"key-openai-a",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-test-a",
|
|
)],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/?skip=0&limit=50"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let items = payload.as_array().expect("payload should be an array");
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0]["id"], "provider-openai");
|
|
assert_eq!(items[0]["name"], "openai");
|
|
assert_eq!(items[0]["api_format"], "openai:chat");
|
|
assert_eq!(items[0]["base_url"], "https://api.openai.example");
|
|
assert_eq!(items[0]["api_key"], "***");
|
|
assert_eq!(items[0]["priority"], 10);
|
|
assert_eq!(items[0]["created_at"], "2024-03-21T05:46:40Z");
|
|
assert_eq!(items[0]["updated_at"], "2024-03-21T05:48:20Z");
|
|
assert_eq!(items[1]["id"], "provider-anthropic");
|
|
assert_eq!(items[1]["api_format"], serde_json::Value::Null);
|
|
assert_eq!(items[1]["base_url"], serde_json::Value::Null);
|
|
assert_eq!(items[1]["api_key"], serde_json::Value::Null);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_providers_locally_with_local_503_when_catalog_reader_unavailable() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/?skip=0&limit=50"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["detail"], ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_summary_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai/summary",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider = sample_provider("provider-openai", "openai", 10)
|
|
.with_description(Some("OpenAI primary provider".to_string()))
|
|
.with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
Some(4),
|
|
Some(json!({"host": "proxy.example", "password": "secret"})),
|
|
Some(45.0),
|
|
Some(12.0),
|
|
Some(json!({
|
|
"claude_code_advanced": {"pool_size": 3},
|
|
"pool_advanced": {"enabled": true},
|
|
"failover_rules": {"strategy": "ordered"},
|
|
"chat_pii_redaction": {"enabled": true},
|
|
"provider_ops": {"architecture_id": "anyrouter"}
|
|
})),
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider.with_timestamps(Some(1_711_000_000), Some(1_711_000_100))],
|
|
vec![
|
|
sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
),
|
|
sample_endpoint(
|
|
"endpoint-openai-cli",
|
|
"provider-openai",
|
|
"openai:responses",
|
|
"https://api.openai.example",
|
|
),
|
|
],
|
|
vec![
|
|
sample_key(
|
|
"key-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-test-chat",
|
|
)
|
|
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.25}})), None),
|
|
sample_key(
|
|
"key-openai-cli",
|
|
"provider-openai",
|
|
"openai:responses",
|
|
"sk-test-cli",
|
|
)
|
|
.with_transport_fields(
|
|
Some(json!(["openai:responses"])),
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-test-cli-2")
|
|
.expect("api key ciphertext should build"),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("key transport should build")
|
|
.with_health_fields(
|
|
Some(json!({"openai:responses": {"health_score": 0.75}})),
|
|
None,
|
|
),
|
|
],
|
|
));
|
|
let global_model_repository = Arc::new(
|
|
InMemoryGlobalModelReadRepository::seed(Vec::new())
|
|
.with_provider_model_stats(vec![sample_provider_model_stats("provider-openai", 5, 3)])
|
|
.with_active_global_model_refs(vec![
|
|
sample_provider_active_global_model("provider-openai", "gpt-5"),
|
|
sample_provider_active_global_model("provider-openai", "gpt-5-mini"),
|
|
]),
|
|
);
|
|
let quota_repository = Arc::new(InMemoryProviderQuotaRepository::seed(vec![
|
|
sample_provider_quota("provider-openai"),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_global_model_and_quota_readers_for_tests(
|
|
provider_catalog_repository,
|
|
global_model_repository,
|
|
quota_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/summary"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["id"], "provider-openai");
|
|
assert_eq!(payload["name"], "openai");
|
|
assert_eq!(payload["description"], "OpenAI primary provider");
|
|
assert_eq!(payload["billing_type"], "monthly_quota");
|
|
assert_eq!(payload["monthly_quota_usd"], 100.0);
|
|
assert_eq!(payload["monthly_used_usd"], 12.5);
|
|
assert_eq!(payload["total_endpoints"], 2);
|
|
assert_eq!(payload["active_endpoints"], 2);
|
|
assert_eq!(payload["total_keys"], 2);
|
|
assert_eq!(payload["active_keys"], 2);
|
|
assert_eq!(payload["total_models"], 5);
|
|
assert_eq!(payload["active_models"], 3);
|
|
assert_eq!(payload["global_model_ids"], json!(["gpt-5", "gpt-5-mini"]));
|
|
assert_eq!(
|
|
payload["api_formats"],
|
|
json!(["openai:chat", "openai:responses"])
|
|
);
|
|
assert_eq!(payload["ops_configured"], true);
|
|
assert_eq!(payload["ops_architecture_id"], "anyrouter");
|
|
assert_eq!(payload["chat_pii_redaction"], json!({"enabled": true}));
|
|
assert_eq!(payload["created_at"], "2024-03-21T05:46:40Z");
|
|
assert_eq!(payload["updated_at"], "2024-03-21T05:48:20Z");
|
|
assert_eq!(
|
|
payload["endpoint_health_details"],
|
|
json!([
|
|
{
|
|
"api_format": "openai:chat",
|
|
"health_score": 0.25,
|
|
"is_active": true,
|
|
"total_keys": 1,
|
|
"active_keys": 1
|
|
},
|
|
{
|
|
"api_format": "openai:responses",
|
|
"health_score": 0.75,
|
|
"is_active": true,
|
|
"total_keys": 1,
|
|
"active_keys": 1
|
|
}
|
|
])
|
|
);
|
|
assert_eq!(payload["avg_health_score"], 0.5);
|
|
assert_eq!(payload["unhealthy_endpoints"], 1);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_providers_summary_list_locally_with_bearer_admin_session() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/summary",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10)
|
|
.with_timestamps(Some(1_711_000_000), Some(1_711_000_100)),
|
|
sample_provider("provider-anthropic", "anthropic", 20)
|
|
.with_timestamps(Some(1_710_000_000), Some(1_710_000_100)),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
));
|
|
let access_token = issue_test_admin_access_token(&state, "device-admin-providers").await;
|
|
let gateway = build_router_with_state(state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/summary?page=1&page_size=20"
|
|
))
|
|
.header("authorization", format!("Bearer {access_token}"))
|
|
.header("x-client-device-id", "device-admin-providers")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let items = payload["items"].as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 2);
|
|
let mut ids = items
|
|
.iter()
|
|
.filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
|
|
.collect::<Vec<_>>();
|
|
ids.sort_unstable();
|
|
assert_eq!(ids, vec!["provider-anthropic", "provider-openai"]);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_summary_locally_with_bearer_admin_session() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai/summary",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)
|
|
.with_description(Some("OpenAI primary provider".to_string()))
|
|
.with_timestamps(Some(1_711_000_000), Some(1_711_000_100))],
|
|
vec![sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
)],
|
|
vec![sample_key(
|
|
"key-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-test-chat",
|
|
)],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
));
|
|
let access_token = issue_test_admin_access_token(&state, "device-admin-provider-summary").await;
|
|
let gateway = build_router_with_state(state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/summary"
|
|
))
|
|
.header("authorization", format!("Bearer {access_token}"))
|
|
.header("x-client-device-id", "device-admin-provider-summary")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["id"], "provider-openai");
|
|
assert_eq!(payload["name"], "openai");
|
|
assert_eq!(payload["description"], "OpenAI primary provider");
|
|
assert_eq!(payload["total_endpoints"], 1);
|
|
assert_eq!(payload["total_keys"], 1);
|
|
assert_eq!(payload["api_formats"], json!(["openai:chat"]));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_providers_summary_list_locally_with_local_503_when_catalog_reader_unavailable(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/summary",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let state = AppState::new().expect("gateway should build");
|
|
let access_token = issue_test_admin_access_token(&state, "device-admin-providers-503").await;
|
|
let gateway = build_router_with_state(state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/summary?page=1&page_size=20"
|
|
))
|
|
.header("authorization", format!("Bearer {access_token}"))
|
|
.header("x-client-device-id", "device-admin-providers-503")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["detail"], ADMIN_PROVIDERS_DATA_UNAVAILABLE_DETAIL);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_providers_summary_list_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/summary",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10)
|
|
.with_description(Some("OpenAI primary provider".to_string()))
|
|
.with_timestamps(Some(1_711_000_000), Some(1_711_000_100)),
|
|
sample_provider("provider-anthropic", "anthropic", 20)
|
|
.with_description(Some("Anthropic backup provider".to_string()))
|
|
.with_transport_fields(false, false, true, None, Some(2), None, None, None, None)
|
|
.with_timestamps(Some(1_710_000_000), Some(1_710_000_100)),
|
|
],
|
|
vec![
|
|
sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
),
|
|
sample_endpoint(
|
|
"endpoint-anthropic-chat",
|
|
"provider-anthropic",
|
|
"claude:messages",
|
|
"https://api.anthropic.example",
|
|
),
|
|
],
|
|
vec![sample_key(
|
|
"key-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-test-chat",
|
|
)
|
|
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.8}})), None)],
|
|
));
|
|
let global_model_repository = Arc::new(
|
|
InMemoryGlobalModelReadRepository::seed(Vec::new())
|
|
.with_provider_model_stats(vec![
|
|
sample_provider_model_stats("provider-openai", 5, 3),
|
|
sample_provider_model_stats("provider-anthropic", 2, 1),
|
|
])
|
|
.with_active_global_model_refs(vec![
|
|
sample_provider_active_global_model("provider-openai", "gpt-5"),
|
|
sample_provider_active_global_model("provider-anthropic", "claude-sonnet-4-5"),
|
|
]),
|
|
);
|
|
let quota_repository = Arc::new(InMemoryProviderQuotaRepository::seed(vec![
|
|
sample_provider_quota("provider-openai"),
|
|
sample_provider_quota("provider-anthropic"),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_global_model_and_quota_readers_for_tests(
|
|
provider_catalog_repository,
|
|
global_model_repository,
|
|
quota_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/summary?page=1&page_size=20&search=open&status=active&api_format=openai:chat&model_id=gpt-5"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["page"], 1);
|
|
assert_eq!(payload["page_size"], 20);
|
|
let items = payload["items"]
|
|
.as_array()
|
|
.expect("items should be an array");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["id"], "provider-openai");
|
|
assert_eq!(items[0]["name"], "openai");
|
|
assert_eq!(items[0]["total_models"], 5);
|
|
assert_eq!(items[0]["active_models"], 3);
|
|
assert_eq!(items[0]["global_model_ids"], json!(["gpt-5"]));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_service_unavailable_for_admin_providers_summary_without_provider_catalog_reader(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/summary",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/summary?page=1&page_size=20"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["detail"], "Admin provider catalog data unavailable");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_service_unavailable_for_admin_provider_create_without_provider_catalog_writer(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/providers"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "openai",
|
|
"provider_type": "openai",
|
|
"provider_priority": 10
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["detail"], "Admin provider catalog data unavailable");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_updates_admin_provider_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10)
|
|
.with_transport_fields(
|
|
true,
|
|
false,
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"pool_advanced": {},
|
|
"failover_rules": {"strategy": "ordered"}
|
|
})),
|
|
)
|
|
.with_timestamps(Some(1_711_000_000), Some(1_711_000_100)),
|
|
sample_provider("provider-other", "other", 20),
|
|
],
|
|
vec![sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
)],
|
|
Vec::new(),
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.patch(format!("{gateway_url}/api/admin/providers/provider-openai"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "openai-renamed",
|
|
"provider_type": "claude_code",
|
|
"description": "Updated provider",
|
|
"website": "https://updated.example",
|
|
"provider_priority": 3,
|
|
"keep_priority_on_conversion": true,
|
|
"is_active": false,
|
|
"concurrent_limit": 8,
|
|
"max_retries": 6,
|
|
"request_timeout": 55.0,
|
|
"stream_first_byte_timeout": 11.0,
|
|
"enable_format_conversion": false,
|
|
"config": {
|
|
"provider_ops": {"architecture_id": "cubence"},
|
|
"chat_pii_redaction": {"enabled": true}
|
|
},
|
|
"claude_code_advanced": {"pool_size": 2},
|
|
"proxy": {"url": "https://proxy.example"}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let body = response.text().await.expect("body should read");
|
|
assert_eq!(status, StatusCode::OK, "body={body}");
|
|
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
|
assert_eq!(payload["id"], "provider-openai");
|
|
assert_eq!(payload["name"], "openai-renamed");
|
|
assert_eq!(payload["provider_type"], "claude_code");
|
|
assert_eq!(payload["description"], "Updated provider");
|
|
assert_eq!(payload["website"], "https://updated.example");
|
|
assert_eq!(payload["provider_priority"], 3);
|
|
assert_eq!(payload["keep_priority_on_conversion"], true);
|
|
assert_eq!(payload["enable_format_conversion"], false);
|
|
assert_eq!(payload["is_active"], false);
|
|
assert_eq!(payload["max_retries"], 6);
|
|
assert_eq!(payload["request_timeout"], 55.0);
|
|
assert_eq!(payload["stream_first_byte_timeout"], 11.0);
|
|
assert_eq!(payload["proxy"], json!({"url": "https://proxy.example"}));
|
|
assert_eq!(payload["claude_code_advanced"], json!({"pool_size": 2}));
|
|
assert_eq!(payload["pool_advanced"], json!({}));
|
|
assert_eq!(payload["failover_rules"], json!({"strategy": "ordered"}));
|
|
assert_eq!(payload["chat_pii_redaction"], json!({"enabled": true}));
|
|
assert_eq!(payload["ops_configured"], true);
|
|
assert_eq!(payload["ops_architecture_id"], "cubence");
|
|
|
|
let disable_response = reqwest::Client::new()
|
|
.patch(format!("{gateway_url}/api/admin/providers/provider-openai"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"config": {
|
|
"chat_pii_redaction": {"enabled": false}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
let disable_status = disable_response.status();
|
|
let disable_body = disable_response.text().await.expect("body should read");
|
|
assert_eq!(disable_status, StatusCode::OK, "body={disable_body}");
|
|
let disable_payload: serde_json::Value =
|
|
serde_json::from_str(&disable_body).expect("json body should parse");
|
|
assert_eq!(
|
|
disable_payload["chat_pii_redaction"],
|
|
json!({"enabled": false})
|
|
);
|
|
assert_eq!(disable_payload["pool_advanced"], json!({}));
|
|
assert_eq!(
|
|
disable_payload["failover_rules"],
|
|
json!({"strategy": "ordered"})
|
|
);
|
|
assert_eq!(disable_payload["ops_architecture_id"], "cubence");
|
|
|
|
let invalid_response = reqwest::Client::new()
|
|
.patch(format!("{gateway_url}/api/admin/providers/provider-openai"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"config": {
|
|
"chat_pii_redaction": {"enabled": true, "entities": ["email"]}
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(invalid_response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let providers = provider_catalog_repository
|
|
.list_providers(false)
|
|
.await
|
|
.expect("providers should list");
|
|
let updated_provider = providers
|
|
.iter()
|
|
.find(|provider| provider.id == "provider-openai")
|
|
.expect("provider should exist");
|
|
assert_eq!(
|
|
updated_provider
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("chat_pii_redaction"))
|
|
.cloned(),
|
|
Some(json!({"enabled": false}))
|
|
);
|
|
assert_eq!(
|
|
updated_provider
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("pool_advanced"))
|
|
.cloned(),
|
|
Some(json!({}))
|
|
);
|
|
assert_eq!(
|
|
updated_provider
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("failover_rules"))
|
|
.cloned(),
|
|
Some(json!({"strategy": "ordered"}))
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_creates_admin_provider_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-existing", "existing", 0)],
|
|
Vec::new(),
|
|
Vec::new(),
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/providers/"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "codex-provider",
|
|
"provider_type": "codex",
|
|
"description": "Codex managed provider",
|
|
"website": "codex.example",
|
|
"keep_priority_on_conversion": true,
|
|
"max_retries": 7,
|
|
"config": {"chat_pii_redaction": {"enabled": true}},
|
|
"pool_advanced": {},
|
|
"failover_rules": {"strategy": "ordered"},
|
|
"proxy": {"url": "https://proxy.example"}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let body = response.text().await.expect("body should read");
|
|
assert_eq!(status, StatusCode::OK, "body={body}");
|
|
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
|
assert_eq!(payload["name"], "codex-provider");
|
|
assert_eq!(payload["message"], "提供商创建成功");
|
|
|
|
let providers = provider_catalog_repository
|
|
.list_providers(false)
|
|
.await
|
|
.expect("providers should list");
|
|
let created = providers
|
|
.iter()
|
|
.find(|provider| provider.name == "codex-provider")
|
|
.expect("created provider should exist");
|
|
let existing = providers
|
|
.iter()
|
|
.find(|provider| provider.id == "provider-existing")
|
|
.expect("existing provider should remain");
|
|
assert_eq!(created.provider_type, "codex");
|
|
assert_eq!(created.provider_priority, 0);
|
|
assert_eq!(existing.provider_priority, 1);
|
|
assert_eq!(created.website.as_deref(), Some("https://codex.example"));
|
|
assert!(created.enable_format_conversion);
|
|
assert_eq!(created.max_retries, Some(7));
|
|
assert_eq!(created.keep_priority_on_conversion, true);
|
|
assert_eq!(
|
|
created
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("pool_advanced"))
|
|
.cloned(),
|
|
Some(json!({}))
|
|
);
|
|
assert_eq!(
|
|
created
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("chat_pii_redaction"))
|
|
.cloned(),
|
|
Some(json!({"enabled": true}))
|
|
);
|
|
assert_eq!(
|
|
created
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("failover_rules"))
|
|
.cloned(),
|
|
Some(json!({"strategy": "ordered"}))
|
|
);
|
|
|
|
let invalid_response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/providers/"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "invalid-redaction-provider",
|
|
"provider_type": "custom",
|
|
"config": {"chat_pii_redaction": {"enabled": true, "entities": ["email"]}}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(invalid_response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let endpoints = provider_catalog_repository
|
|
.list_endpoints_by_provider_ids(std::slice::from_ref(&created.id))
|
|
.await
|
|
.expect("endpoints should list");
|
|
assert_eq!(endpoints.len(), 3);
|
|
let responses_endpoint = endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.api_format == "openai:responses")
|
|
.expect("responses endpoint should exist");
|
|
let compact_endpoint = endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.api_format == "openai:responses:compact")
|
|
.expect("compact endpoint should exist");
|
|
let image_endpoint = endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.api_format == "openai:image")
|
|
.expect("image endpoint should exist");
|
|
assert_eq!(
|
|
responses_endpoint.base_url,
|
|
"https://chatgpt.com/backend-api/codex"
|
|
);
|
|
assert_eq!(
|
|
compact_endpoint.base_url,
|
|
"https://chatgpt.com/backend-api/codex"
|
|
);
|
|
assert_eq!(
|
|
image_endpoint.base_url,
|
|
"https://chatgpt.com/backend-api/codex"
|
|
);
|
|
assert_eq!(responses_endpoint.max_retries, Some(7));
|
|
assert_eq!(compact_endpoint.max_retries, Some(7));
|
|
assert_eq!(image_endpoint.max_retries, Some(7));
|
|
assert_eq!(
|
|
responses_endpoint
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("upstream_stream_policy"))
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("force_stream")
|
|
);
|
|
assert_eq!(
|
|
image_endpoint
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("upstream_stream_policy"))
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("force_stream")
|
|
);
|
|
assert!(responses_endpoint.body_rules.is_none());
|
|
assert!(compact_endpoint.body_rules.is_none());
|
|
assert!(image_endpoint.body_rules.is_none());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_updates_fixed_provider_and_reconciles_template_managed_endpoints() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-codex",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
Some(2),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
provider.provider_type = "codex".to_string();
|
|
let mut cli_endpoint = sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
);
|
|
cli_endpoint.max_retries = Some(2);
|
|
cli_endpoint.config = Some(json!({"upstream_stream_policy": "force_stream"}));
|
|
let mut key = sample_key(
|
|
"key-codex-oauth",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"oauth-placeholder",
|
|
);
|
|
key.auth_type = "oauth".to_string();
|
|
key.api_formats = Some(json!(["openai:responses"]));
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![cli_endpoint],
|
|
vec![key],
|
|
));
|
|
|
|
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.patch(format!("{gateway_url}/api/admin/providers/provider-codex"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"max_retries": 9
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
let status = response.status();
|
|
let body = response.text().await.expect("body should read");
|
|
assert_eq!(status, StatusCode::OK, "body={body}");
|
|
|
|
let endpoints = provider_catalog_repository
|
|
.list_endpoints_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("endpoints should list");
|
|
assert_eq!(endpoints.len(), 3);
|
|
let responses_endpoint = endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.api_format == "openai:responses")
|
|
.expect("responses endpoint should exist");
|
|
let compact_endpoint = endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.api_format == "openai:responses:compact")
|
|
.expect("compact endpoint should exist");
|
|
let image_endpoint = endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.api_format == "openai:image")
|
|
.expect("image endpoint should exist");
|
|
|
|
assert_eq!(responses_endpoint.max_retries, Some(9));
|
|
assert_eq!(compact_endpoint.max_retries, Some(9));
|
|
assert_eq!(image_endpoint.max_retries, Some(9));
|
|
assert_eq!(
|
|
responses_endpoint
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("_aether_fixed_provider_template"))
|
|
.and_then(|value| value.get("managed"))
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
image_endpoint
|
|
.config
|
|
.as_ref()
|
|
.and_then(|value| value.get("upstream_stream_policy"))
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("force_stream")
|
|
);
|
|
let keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
|
.await
|
|
.expect("keys should list");
|
|
assert_eq!(keys.len(), 1);
|
|
assert!(keys[0].api_formats.is_none());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_lists_effective_api_formats_for_fixed_oauth_provider_keys() {
|
|
let mut provider = sample_provider("provider-codex", "codex", 10)
|
|
.with_transport_fields(true, false, true, None, None, None, None, None, None);
|
|
provider.provider_type = "codex".to_string();
|
|
|
|
let mut key = sample_key(
|
|
"key-codex-legacy",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"oauth-placeholder",
|
|
);
|
|
key.name = "codex legacy".to_string();
|
|
key.auth_type = "oauth".to_string();
|
|
key.api_formats = Some(json!(["openai:responses", "openai:responses:compact"]));
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![
|
|
sample_endpoint(
|
|
"endpoint-codex-cli",
|
|
"provider-codex",
|
|
"openai:responses",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
),
|
|
sample_endpoint(
|
|
"endpoint-codex-image",
|
|
"provider-codex",
|
|
"openai:image",
|
|
"https://chatgpt.com/backend-api/codex",
|
|
),
|
|
],
|
|
vec![key],
|
|
));
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/endpoints/providers/provider-codex/keys?skip=0&limit=100"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let keys = payload.as_array().expect("keys payload should be array");
|
|
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(
|
|
keys[0]["api_formats"],
|
|
json!(["openai:image", "openai:responses"])
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_health_monitor_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai/health-monitor",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![
|
|
sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
),
|
|
sample_endpoint(
|
|
"endpoint-openai-cli",
|
|
"provider-openai",
|
|
"openai:responses",
|
|
"https://api.openai.example",
|
|
),
|
|
],
|
|
Vec::new(),
|
|
));
|
|
let now_unix_secs = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("current time should be after epoch")
|
|
.as_secs() as i64;
|
|
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
|
sample_request_candidate(
|
|
"cand-chat-success",
|
|
"req-chat-success",
|
|
"endpoint-openai-chat",
|
|
RequestCandidateStatus::Success,
|
|
now_unix_secs - 3_000,
|
|
Some(now_unix_secs - 2_980),
|
|
),
|
|
sample_request_candidate(
|
|
"cand-chat-failed",
|
|
"req-chat-failed",
|
|
"endpoint-openai-chat",
|
|
RequestCandidateStatus::Failed,
|
|
now_unix_secs - 2_000,
|
|
Some(now_unix_secs - 1_980),
|
|
),
|
|
sample_request_candidate(
|
|
"cand-cli-skipped",
|
|
"req-cli-skipped",
|
|
"endpoint-openai-cli",
|
|
RequestCandidateStatus::Skipped,
|
|
now_unix_secs - 1_500,
|
|
Some(now_unix_secs - 1_490),
|
|
),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_and_request_candidate_reader_for_tests(
|
|
provider_catalog_repository,
|
|
request_candidate_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/health-monitor?lookback_hours=6&per_endpoint_limit=48"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_id"], "provider-openai");
|
|
assert_eq!(payload["provider_name"], "openai");
|
|
assert!(payload["generated_at"].as_str().is_some());
|
|
let endpoints = payload["endpoints"]
|
|
.as_array()
|
|
.expect("endpoints should be an array");
|
|
assert_eq!(endpoints.len(), 2);
|
|
assert_eq!(endpoints[0]["endpoint_id"], "endpoint-openai-chat");
|
|
assert_eq!(endpoints[0]["api_format"], "openai:chat");
|
|
assert_eq!(endpoints[0]["total_attempts"], 2);
|
|
assert_eq!(endpoints[0]["success_count"], 1);
|
|
assert_eq!(endpoints[0]["failed_count"], 1);
|
|
assert_eq!(endpoints[0]["skipped_count"], 0);
|
|
assert_eq!(endpoints[0]["success_rate"], 0.5);
|
|
assert!(endpoints[0]["last_event_at"]
|
|
.as_str()
|
|
.is_some_and(|value| value.ends_with(".000Z")));
|
|
assert_eq!(
|
|
endpoints[0]["events"]
|
|
.as_array()
|
|
.expect("events should be an array")
|
|
.len(),
|
|
2
|
|
);
|
|
assert!(endpoints[0]["events"][0]["timestamp"]
|
|
.as_str()
|
|
.is_some_and(|value| value.ends_with(".000Z")));
|
|
assert_eq!(endpoints[1]["endpoint_id"], "endpoint-openai-cli");
|
|
assert_eq!(endpoints[1]["api_format"], "openai:responses");
|
|
assert_eq!(endpoints[1]["total_attempts"], 1);
|
|
assert_eq!(endpoints[1]["success_count"], 0);
|
|
assert_eq!(endpoints[1]["failed_count"], 0);
|
|
assert_eq!(endpoints[1]["skipped_count"], 1);
|
|
assert_eq!(endpoints[1]["success_rate"], 0.0);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_mapping_preview_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai/mapping-preview",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let mut mapping_key = sample_key(
|
|
"key-openai-preview",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-preview-1234",
|
|
);
|
|
mapping_key.allowed_models = Some(json!(["gpt-5", "gpt-4.1-mini"]));
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
Vec::new(),
|
|
vec![mapping_key],
|
|
));
|
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
|
sample_public_global_model_with_mappings("global-gpt-5", "gpt-5", "GPT 5", &["gpt-5"]),
|
|
sample_public_global_model_with_mappings(
|
|
"global-gpt-4.1-mini",
|
|
"gpt-4.1-mini",
|
|
"GPT 4.1 mini",
|
|
&["gpt-4\\.1-.*"],
|
|
),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)
|
|
.with_global_model_repository_for_tests(global_model_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/mapping-preview"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_id"], "provider-openai");
|
|
assert_eq!(payload["provider_name"], "openai");
|
|
assert_eq!(payload["total_keys"], 1);
|
|
assert_eq!(payload["total_matches"], 2);
|
|
assert_eq!(payload["truncated"], false);
|
|
|
|
let keys = payload["keys"].as_array().expect("keys should be an array");
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(keys[0]["key_id"], "key-openai-preview");
|
|
assert_eq!(keys[0]["masked_key"], "sk-p***1234");
|
|
assert_eq!(keys[0]["allowed_models"], json!(["gpt-5", "gpt-4.1-mini"]));
|
|
|
|
let matches = keys[0]["matching_global_models"]
|
|
.as_array()
|
|
.expect("matching models should be an array");
|
|
assert_eq!(matches.len(), 2);
|
|
let gpt5 = matches
|
|
.iter()
|
|
.find(|item| item["global_model_name"] == "gpt-5")
|
|
.expect("gpt-5 match should exist");
|
|
let gpt41mini = matches
|
|
.iter()
|
|
.find(|item| item["global_model_name"] == "gpt-4.1-mini")
|
|
.expect("gpt-4.1-mini match should exist");
|
|
assert_eq!(gpt5["global_model_name"], "gpt-5");
|
|
assert_eq!(gpt5["matched_models"][0]["mapping_pattern"], "gpt-5");
|
|
assert_eq!(gpt41mini["global_model_name"], "gpt-4.1-mini");
|
|
assert_eq!(
|
|
gpt41mini["matched_models"][0]["mapping_pattern"],
|
|
"gpt-4\\.1-.*"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_submits_admin_provider_delete_task_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new()
|
|
.route(
|
|
"/api/admin/providers/provider-openai",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/admin/providers/provider-openai/delete-task/{task_id}",
|
|
any(|_request: Request| async move {
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
)],
|
|
vec![sample_key(
|
|
"key-openai-delete",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-delete",
|
|
)],
|
|
));
|
|
let global_model_repository = Arc::new(
|
|
InMemoryGlobalModelReadRepository::seed(Vec::new()).with_admin_provider_models(vec![
|
|
sample_admin_provider_model(
|
|
"model-openai-delete",
|
|
"provider-openai",
|
|
"global-gpt-5",
|
|
"gpt-5",
|
|
),
|
|
]),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository.clone(),
|
|
)
|
|
.with_global_model_repository_for_tests(global_model_repository.clone()),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{gateway_url}/api/admin/providers/provider-openai"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let submit_payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let task_id = submit_payload["task_id"]
|
|
.as_str()
|
|
.expect("task id should be present")
|
|
.to_string();
|
|
assert_eq!(submit_payload["status"], "pending");
|
|
assert_eq!(
|
|
submit_payload["message"],
|
|
"删除任务已提交,提供商已进入后台删除队列"
|
|
);
|
|
|
|
let task_response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/delete-task/{task_id}"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(task_response.status(), StatusCode::OK);
|
|
let task_payload: serde_json::Value =
|
|
task_response.json().await.expect("json body should parse");
|
|
assert_eq!(task_payload["task_id"], task_id);
|
|
assert_eq!(task_payload["provider_id"], "provider-openai");
|
|
assert_eq!(task_payload["status"], "completed");
|
|
assert_eq!(task_payload["stage"], "completed");
|
|
assert_eq!(task_payload["total_keys"], 1);
|
|
assert_eq!(task_payload["deleted_keys"], 1);
|
|
assert_eq!(task_payload["total_endpoints"], 1);
|
|
assert_eq!(task_payload["deleted_endpoints"], 1);
|
|
|
|
let remaining_providers = provider_catalog_repository
|
|
.list_providers(false)
|
|
.await
|
|
.expect("provider list should load");
|
|
assert!(remaining_providers.is_empty());
|
|
let remaining_endpoints = provider_catalog_repository
|
|
.list_endpoints_by_provider_ids(&["provider-openai".to_string()])
|
|
.await
|
|
.expect("endpoint list should load");
|
|
assert!(remaining_endpoints.is_empty());
|
|
let remaining_keys = provider_catalog_repository
|
|
.list_keys_by_provider_ids(&["provider-openai".to_string()])
|
|
.await
|
|
.expect("key list should load");
|
|
assert!(remaining_keys.is_empty());
|
|
let remaining_models = global_model_repository
|
|
.list_admin_provider_models(
|
|
&aether_data_contracts::repository::global_models::AdminProviderModelListQuery {
|
|
provider_id: "provider-openai".to_string(),
|
|
offset: 0,
|
|
limit: 100,
|
|
is_active: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("provider model list should load");
|
|
assert!(remaining_models.is_empty());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_admin_provider_delete_task_status_attaches_audit_only_for_terminal_states() {
|
|
let mut completed_state = AppState::new().expect("gateway should build");
|
|
completed_state.put_provider_delete_task(crate::LocalProviderDeleteTaskState {
|
|
task_id: "task-completed".to_string(),
|
|
provider_id: "provider-openai".to_string(),
|
|
status: "completed".to_string(),
|
|
stage: "completed".to_string(),
|
|
total_keys: 3,
|
|
deleted_keys: 3,
|
|
total_endpoints: 2,
|
|
deleted_endpoints: 2,
|
|
message: "provider deleted".to_string(),
|
|
});
|
|
let completed_response = local_admin_providers_response(
|
|
&completed_state,
|
|
http::Method::GET,
|
|
"/api/admin/providers/provider-openai/delete-task/task-completed",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(completed_response.status(), StatusCode::OK);
|
|
let completed_audit = completed_response
|
|
.extensions()
|
|
.get::<AdminAuditEvent>()
|
|
.expect("completed delete task should attach audit");
|
|
assert_eq!(
|
|
completed_audit.event_name,
|
|
"admin_provider_delete_task_completed_viewed"
|
|
);
|
|
assert_eq!(
|
|
completed_audit.action,
|
|
"view_provider_delete_task_terminal_state"
|
|
);
|
|
assert_eq!(completed_audit.target_type, "provider_delete_task");
|
|
assert_eq!(completed_audit.target_id, "provider-openai:task-completed");
|
|
|
|
let mut running_state = AppState::new().expect("gateway should build");
|
|
running_state.put_provider_delete_task(crate::LocalProviderDeleteTaskState {
|
|
task_id: "task-running".to_string(),
|
|
provider_id: "provider-openai".to_string(),
|
|
status: "running".to_string(),
|
|
stage: "deleting_keys".to_string(),
|
|
total_keys: 3,
|
|
deleted_keys: 1,
|
|
total_endpoints: 2,
|
|
deleted_endpoints: 0,
|
|
message: "deleted 1 / 3 keys".to_string(),
|
|
});
|
|
let running_response = local_admin_providers_response(
|
|
&running_state,
|
|
http::Method::GET,
|
|
"/api/admin/providers/provider-openai/delete-task/task-running",
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(running_response.status(), StatusCode::OK);
|
|
assert!(
|
|
running_response
|
|
.extensions()
|
|
.get::<AdminAuditEvent>()
|
|
.is_none(),
|
|
"running delete task should not attach audit"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_pool_status_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai/pool-status",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider = sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"pool_advanced": {
|
|
"lru_enabled": true,
|
|
"cost_window_seconds": 7200,
|
|
"cost_limit_per_key_tokens": 12000
|
|
}
|
|
})),
|
|
);
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
Vec::new(),
|
|
vec![sample_key(
|
|
"key-openai-pool",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-test",
|
|
)],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/pool-status"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_id"], "provider-openai");
|
|
assert_eq!(payload["provider_name"], "openai");
|
|
assert_eq!(payload["pool_enabled"], true);
|
|
assert_eq!(payload["total_keys"], 1);
|
|
assert_eq!(payload["total_sticky_sessions"], 0);
|
|
assert_eq!(payload["provider_hot_count"], 0);
|
|
assert_eq!(payload["provider_desired_hot"], 0);
|
|
assert_eq!(payload["provider_in_flight"], 0);
|
|
assert_eq!(payload["provider_ema_in_flight"], 0.0);
|
|
assert_eq!(payload["provider_burst_pending"], false);
|
|
let keys = payload["keys"].as_array().expect("keys should be an array");
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(keys[0]["key_id"], "key-openai-pool");
|
|
assert_eq!(keys[0]["key_name"], "default");
|
|
assert_eq!(keys[0]["cost_window_usage"], 0);
|
|
assert_eq!(keys[0]["cost_limit"], 12000);
|
|
assert_eq!(keys[0]["sticky_sessions"], 0);
|
|
assert!(keys[0]["lru_score"].is_null());
|
|
assert!(keys[0]["cooldown_reason"].is_null());
|
|
assert!(keys[0]["cooldown_ttl_seconds"].is_null());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_clears_admin_provider_pool_cooldown_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai/pool/clear-cooldown/key-openai-pool",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
Vec::new(),
|
|
vec![sample_key(
|
|
"key-openai-pool",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-test",
|
|
)],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/pool/clear-cooldown/key-openai-pool"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["message"], "已清除 Key default 的冷却状态");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_resets_admin_provider_pool_cost_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/providers/provider-openai/pool/reset-cost/key-openai-pool",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
Vec::new(),
|
|
vec![sample_key(
|
|
"key-openai-pool",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"sk-test",
|
|
)],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
|
provider_catalog_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/providers/provider-openai/pool/reset-cost/key-openai-pool"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["message"], "已重置 Key default 的成本窗口");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|