mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 增强 provider query 端点选择逻辑,支持 openai:responses 格式与 key 级 api_formats 过滤
- provider_query_selected_fetch_endpoints 支持按 key.api_formats 过滤可用端点 - 端点选择增加 endpoint_supports_rust_models_fetch 检查 - 支持优先级列表之外的自定义 api_format 端点 - 新增 openai:responses 端点的 provider query 集成测试 - 各 crate 补充缺失的 tracing instrument 和小修正 - 前端 Pool 管理页面补充状态逻辑
This commit is contained in:
@@ -174,7 +174,7 @@ fn respects_existing_codex_request_and_session_headers() {
|
||||
Some(&"kept-by-rule-request".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("session_id"), Some(&"kept-by-rule".to_string()));
|
||||
assert!(headers.get("conversation_id").is_none());
|
||||
assert!(!headers.contains_key("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,5 +207,5 @@ fn skips_conversation_id_for_compact_codex_requests() {
|
||||
headers.get("session_id"),
|
||||
Some(&"ab5ecce4f0d110fe".to_string())
|
||||
);
|
||||
assert!(headers.get("conversation_id").is_none());
|
||||
assert!(!headers.contains_key("conversation_id"));
|
||||
}
|
||||
|
||||
@@ -389,8 +389,7 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
is_antigravity: is_antigravity
|
||||
|| matches!(antigravity_auth, Some(_))
|
||||
&& ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal",
|
||||
|| antigravity_auth.is_some() && ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal",
|
||||
upstream_is_stream,
|
||||
transport,
|
||||
})
|
||||
|
||||
@@ -86,6 +86,7 @@ pub(super) fn build_admin_global_model_price_range(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::redundant_iter_cloned, clippy::redundant_locals)]
|
||||
pub(super) async fn admin_global_model_provider_models_by_global_model_id(
|
||||
state: &AdminAppState<'_>,
|
||||
global_model_ids: &[String],
|
||||
|
||||
@@ -41,9 +41,11 @@ pub(in super::super) async fn build_admin_monitoring_cache_users_delete_response
|
||||
return Ok(admin_monitoring_cache_affinity_unavailable_response());
|
||||
}
|
||||
|
||||
let direct_api_key_by_id =
|
||||
admin_monitoring_list_export_api_key_records_by_ids(state, &[user_identifier.clone()])
|
||||
.await?;
|
||||
let direct_api_key_by_id = admin_monitoring_list_export_api_key_records_by_ids(
|
||||
state,
|
||||
std::slice::from_ref(&user_identifier),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(api_key) = direct_api_key_by_id.get(&user_identifier) {
|
||||
let target_affinity_keys =
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::helpers::{
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use aether_admin::provider::verify as admin_provider_verify_pure;
|
||||
use http;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub(super) fn admin_provider_ops_generic_verify_payload(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
AdminProviderPoolConfig, AdminProviderPoolRuntimeState,
|
||||
};
|
||||
use crate::handlers::admin::shared::{
|
||||
provider_key_status_snapshot_payload, unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::{provider_key_status_snapshot_payload, unix_secs_to_rfc3339};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -9,19 +9,19 @@ use super::response::{
|
||||
ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL,
|
||||
};
|
||||
use crate::execution_runtime;
|
||||
use crate::model_fetch::ModelFetchRuntimeState;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::model_fetch::ModelFetchRuntimeState;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_model_fetch::{
|
||||
aggregate_models_for_cache, build_models_fetch_execution_plan, extract_error_message,
|
||||
parse_models_response,
|
||||
aggregate_models_for_cache, build_models_fetch_execution_plan,
|
||||
endpoint_supports_rust_models_fetch, extract_error_message, parse_models_response,
|
||||
};
|
||||
use axum::{body::Body, http::Response, response::IntoResponse, Json};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(crate) const ADMIN_PROVIDER_QUERY_LOCAL_TEST_MODEL_MESSAGE: &str =
|
||||
"Rust local provider-query model test is not configured";
|
||||
@@ -32,7 +32,12 @@ const ADMIN_PROVIDER_QUERY_NO_ACTIVE_ENDPOINT_DETAIL: &str =
|
||||
const ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL: &str =
|
||||
"No models returned from any endpoint";
|
||||
const PROVIDER_QUERY_FETCH_FORMAT_PRIORITY: &[&[&str]] = &[
|
||||
&["openai:chat", "openai:cli", "openai:compact"],
|
||||
&[
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
],
|
||||
&["claude:chat", "claude:cli"],
|
||||
&["gemini:chat", "gemini:cli"],
|
||||
];
|
||||
@@ -67,26 +72,56 @@ fn provider_query_normalize_api_format(value: &str) -> String {
|
||||
|
||||
fn provider_query_selected_fetch_endpoints(
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
key: &StoredProviderCatalogKey,
|
||||
) -> Vec<StoredProviderCatalogEndpoint> {
|
||||
let allowed_api_formats = key
|
||||
.api_formats
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(provider_query_normalize_api_format)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<BTreeSet<_>>()
|
||||
})
|
||||
.filter(|items| !items.is_empty());
|
||||
let mut by_format = BTreeMap::<String, StoredProviderCatalogEndpoint>::new();
|
||||
for endpoint in endpoints.iter().filter(|endpoint| endpoint.is_active) {
|
||||
let api_format = provider_query_normalize_api_format(&endpoint.api_format);
|
||||
if api_format.is_empty() {
|
||||
if api_format.is_empty() || !endpoint_supports_rust_models_fetch(&api_format) {
|
||||
continue;
|
||||
}
|
||||
if allowed_api_formats
|
||||
.as_ref()
|
||||
.is_some_and(|formats| !formats.contains(&api_format))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
by_format.insert(api_format, endpoint.clone());
|
||||
}
|
||||
|
||||
// 与 Python 版本保持一致:同族优先使用 chat 端点,其次才回退到 cli/compact。
|
||||
PROVIDER_QUERY_FETCH_FORMAT_PRIORITY
|
||||
// 与 Python 版本保持一致:同族优先使用 chat 端点,其次才回退到其他抓取格式。
|
||||
let covered_formats = PROVIDER_QUERY_FETCH_FORMAT_PRIORITY
|
||||
.iter()
|
||||
.flat_map(|items| items.iter().copied())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut selected = PROVIDER_QUERY_FETCH_FORMAT_PRIORITY
|
||||
.iter()
|
||||
.filter_map(|candidates| {
|
||||
candidates
|
||||
.iter()
|
||||
.find_map(|api_format| by_format.get(*api_format))
|
||||
.cloned()
|
||||
.find_map(|api_format| by_format.remove(*api_format))
|
||||
})
|
||||
.collect()
|
||||
.collect::<Vec<_>>();
|
||||
selected.extend(
|
||||
by_format
|
||||
.into_iter()
|
||||
.filter(|(api_format, _)| !covered_formats.contains(api_format.as_str()))
|
||||
.map(|(_, endpoint)| endpoint),
|
||||
);
|
||||
selected
|
||||
}
|
||||
|
||||
async fn provider_query_read_cached_models(
|
||||
@@ -165,7 +200,7 @@ async fn provider_query_fetch_models_for_key(
|
||||
}
|
||||
}
|
||||
|
||||
let selected_endpoints = provider_query_selected_fetch_endpoints(endpoints);
|
||||
let selected_endpoints = provider_query_selected_fetch_endpoints(endpoints, key);
|
||||
if selected_endpoints.is_empty() {
|
||||
return Ok(ProviderQueryKeyFetchResult {
|
||||
models: Vec::new(),
|
||||
@@ -294,9 +329,14 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
let mut cache_hit_count = 0usize;
|
||||
let mut fetch_count = 0usize;
|
||||
for key in active_keys {
|
||||
let result =
|
||||
provider_query_fetch_models_for_key(state.app(), &provider, &endpoints, key, force_refresh)
|
||||
.await?;
|
||||
let result = provider_query_fetch_models_for_key(
|
||||
state.app(),
|
||||
&provider,
|
||||
&endpoints,
|
||||
key,
|
||||
force_refresh,
|
||||
)
|
||||
.await?;
|
||||
all_models.extend(result.models);
|
||||
if let Some(error) = result.error {
|
||||
all_errors.push(format!(
|
||||
|
||||
@@ -40,7 +40,11 @@ pub(crate) async fn build_admin_provider_keys_payload(
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.map(|key| {
|
||||
state.build_admin_provider_key_response(&key, &provider.provider_type, now_unix_secs)
|
||||
state.build_admin_provider_key_response(
|
||||
&key,
|
||||
&provider.provider_type,
|
||||
now_unix_secs,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
|
||||
@@ -661,7 +661,8 @@ fn admin_provider_crud_mod_stays_thin() {
|
||||
|
||||
let crud_routes =
|
||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/crud/routes.rs");
|
||||
for pattern in ["maybe_build_admin_provider_crud_route_response("] {
|
||||
{
|
||||
let pattern = "maybe_build_admin_provider_crud_route_response(";
|
||||
assert!(
|
||||
crud_routes.contains(pattern),
|
||||
"handlers/admin/provider/crud/routes.rs should delegate through request/provider owner {pattern}"
|
||||
@@ -2191,7 +2192,8 @@ fn admin_provider_endpoint_keys_mod_stays_thin() {
|
||||
fn admin_provider_models_own_provider_model_builders() {
|
||||
let provider_models_mod =
|
||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/models/mod.rs");
|
||||
for pattern in ["mod payloads;"] {
|
||||
{
|
||||
let pattern = "mod payloads;";
|
||||
assert!(
|
||||
provider_models_mod.contains(pattern),
|
||||
"handlers/admin/provider/models/mod.rs should register local provider-model owner module {pattern}"
|
||||
|
||||
@@ -146,7 +146,8 @@ fn admin_system_and_endpoint_roots_stay_thin() {
|
||||
),
|
||||
"handlers/admin/endpoint/routes.rs should dispatch provider endpoint CRUD directly to provider::endpoints_admin"
|
||||
);
|
||||
for path in ["apps/aether-gateway/src/handlers/admin/endpoint/keys.rs"] {
|
||||
{
|
||||
let path = "apps/aether-gateway/src/handlers/admin/endpoint/keys.rs";
|
||||
assert!(
|
||||
!workspace_file_exists(path),
|
||||
"{path} should be deleted once endpoint root dispatches directly to provider-owned handlers"
|
||||
|
||||
@@ -1142,7 +1142,8 @@ fn ai_pipeline_matrix_conversion_is_owned_by_pipeline_crate() {
|
||||
!matrix.contains("mod matrix;"),
|
||||
"planner/standard/mod.rs should not keep a local matrix wrapper module"
|
||||
);
|
||||
for forbidden in ["serde_json::Map::from_iter"] {
|
||||
{
|
||||
let forbidden = "serde_json::Map::from_iter";
|
||||
assert!(
|
||||
!matrix.contains(forbidden),
|
||||
"planner/standard/mod.rs should not keep matrix conversion helper {forbidden}"
|
||||
|
||||
@@ -185,6 +185,243 @@ async fn gateway_handles_admin_provider_query_models_fetches_upstream_for_select
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_query_models_with_openai_responses_endpoint() {
|
||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
|
||||
async move {
|
||||
*execution_runtime_hits_inner
|
||||
.lock()
|
||||
.expect("mutex should lock") += 1;
|
||||
assert_eq!(plan.endpoint_id, "endpoint-openai-responses");
|
||||
assert_eq!(plan.provider_api_format, "openai:responses");
|
||||
Json(json!({
|
||||
"request_id": "req-provider-query-responses",
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"data": [{
|
||||
"id": "gpt-4.1",
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"created": 1732517497u64
|
||||
}]
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-openai", "OpenAI", 10);
|
||||
provider.provider_type = "openai".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-openai-responses".to_string(),
|
||||
"provider-openai".to_string(),
|
||||
"openai:responses".to_string(),
|
||||
Some("responses".to_string()),
|
||||
Some("primary".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build")],
|
||||
vec![sample_key(
|
||||
"key-openai-responses",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
"sk-test-responses",
|
||||
)],
|
||||
));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
DEVELOPMENT_ENCRYPTION_KEY.to_string(),
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/provider-query/models"))
|
||||
.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!({
|
||||
"provider_id": "provider-openai",
|
||||
"api_key_id": "key-openai-responses"
|
||||
}))
|
||||
.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["success"], json!(true));
|
||||
assert_eq!(payload["data"]["error"], serde_json::Value::Null);
|
||||
assert_eq!(payload["data"]["from_cache"], json!(false));
|
||||
assert_eq!(
|
||||
payload["data"]["models"][0]["api_formats"],
|
||||
json!(["openai:responses"])
|
||||
);
|
||||
assert_eq!(
|
||||
*execution_runtime_hits.lock().expect("mutex should lock"),
|
||||
1
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_query_models_respecting_key_api_formats() {
|
||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
|
||||
async move {
|
||||
*execution_runtime_hits_inner
|
||||
.lock()
|
||||
.expect("mutex should lock") += 1;
|
||||
assert_eq!(plan.endpoint_id, "endpoint-openai-cli");
|
||||
assert_eq!(plan.provider_api_format, "openai:cli");
|
||||
Json(json!({
|
||||
"request_id": "req-provider-query-cli",
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"data": [{
|
||||
"id": "gpt-5-cli",
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"created": 1732517497u64
|
||||
}]
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-openai", "OpenAI", 10);
|
||||
provider.provider_type = "openai".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-openai-chat".to_string(),
|
||||
"provider-openai".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
Some("chat".to_string()),
|
||||
Some("primary".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build"),
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-openai-cli".to_string(),
|
||||
"provider-openai".to_string(),
|
||||
"openai:cli".to_string(),
|
||||
Some("cli".to_string()),
|
||||
Some("secondary".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build"),
|
||||
],
|
||||
vec![sample_key(
|
||||
"key-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:cli",
|
||||
"sk-test-cli",
|
||||
)],
|
||||
));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
DEVELOPMENT_ENCRYPTION_KEY.to_string(),
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/provider-query/models"))
|
||||
.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!({
|
||||
"provider_id": "provider-openai",
|
||||
"api_key_id": "key-openai-cli"
|
||||
}))
|
||||
.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["success"], json!(true));
|
||||
assert_eq!(payload["data"]["error"], serde_json::Value::Null);
|
||||
assert_eq!(payload["data"]["from_cache"], json!(false));
|
||||
assert_eq!(payload["data"]["models"][0]["api_formats"], json!(["openai:cli"]));
|
||||
assert_eq!(
|
||||
*execution_runtime_hits.lock().expect("mutex should lock"),
|
||||
1
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_query_models_aggregating_active_keys() {
|
||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
Reference in New Issue
Block a user