mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +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())
|
Some(&"kept-by-rule-request".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(headers.get("session_id"), Some(&"kept-by-rule".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]
|
#[test]
|
||||||
@@ -207,5 +207,5 @@ fn skips_conversation_id_for_compact_codex_requests() {
|
|||||||
headers.get("session_id"),
|
headers.get("session_id"),
|
||||||
Some(&"ab5ecce4f0d110fe".to_string())
|
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,
|
execution_strategy,
|
||||||
conversion_mode,
|
conversion_mode,
|
||||||
is_antigravity: is_antigravity
|
is_antigravity: is_antigravity
|
||||||
|| matches!(antigravity_auth, Some(_))
|
|| antigravity_auth.is_some() && ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal",
|
||||||
&& ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal",
|
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
transport,
|
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(
|
pub(super) async fn admin_global_model_provider_models_by_global_model_id(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
global_model_ids: &[String],
|
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());
|
return Ok(admin_monitoring_cache_affinity_unavailable_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let direct_api_key_by_id =
|
let direct_api_key_by_id = admin_monitoring_list_export_api_key_records_by_ids(
|
||||||
admin_monitoring_list_export_api_key_records_by_ids(state, &[user_identifier.clone()])
|
state,
|
||||||
.await?;
|
std::slice::from_ref(&user_identifier),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if let Some(api_key) = direct_api_key_by_id.get(&user_identifier) {
|
if let Some(api_key) = direct_api_key_by_id.get(&user_identifier) {
|
||||||
let target_affinity_keys =
|
let target_affinity_keys =
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use super::helpers::{
|
|||||||
};
|
};
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use aether_admin::provider::verify as admin_provider_verify_pure;
|
use aether_admin::provider::verify as admin_provider_verify_pure;
|
||||||
use http;
|
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
pub(super) fn admin_provider_ops_generic_verify_payload(
|
pub(super) fn admin_provider_ops_generic_verify_payload(
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use crate::handlers::admin::provider::shared::support::{
|
use crate::handlers::admin::provider::shared::support::{
|
||||||
AdminProviderPoolConfig, AdminProviderPoolRuntimeState,
|
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::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 aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,19 @@ use super::response::{
|
|||||||
ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL,
|
ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL,
|
||||||
};
|
};
|
||||||
use crate::execution_runtime;
|
use crate::execution_runtime;
|
||||||
use crate::model_fetch::ModelFetchRuntimeState;
|
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
|
use crate::model_fetch::ModelFetchRuntimeState;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
use aether_model_fetch::{
|
use aether_model_fetch::{
|
||||||
aggregate_models_for_cache, build_models_fetch_execution_plan, extract_error_message,
|
aggregate_models_for_cache, build_models_fetch_execution_plan,
|
||||||
parse_models_response,
|
endpoint_supports_rust_models_fetch, extract_error_message, parse_models_response,
|
||||||
};
|
};
|
||||||
use axum::{body::Body, http::Response, response::IntoResponse, Json};
|
use axum::{body::Body, http::Response, response::IntoResponse, Json};
|
||||||
use serde_json::{json, Value};
|
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 =
|
pub(crate) const ADMIN_PROVIDER_QUERY_LOCAL_TEST_MODEL_MESSAGE: &str =
|
||||||
"Rust local provider-query model test is not configured";
|
"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 =
|
const ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL: &str =
|
||||||
"No models returned from any endpoint";
|
"No models returned from any endpoint";
|
||||||
const PROVIDER_QUERY_FETCH_FORMAT_PRIORITY: &[&[&str]] = &[
|
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"],
|
&["claude:chat", "claude:cli"],
|
||||||
&["gemini:chat", "gemini:cli"],
|
&["gemini:chat", "gemini:cli"],
|
||||||
];
|
];
|
||||||
@@ -67,26 +72,56 @@ fn provider_query_normalize_api_format(value: &str) -> String {
|
|||||||
|
|
||||||
fn provider_query_selected_fetch_endpoints(
|
fn provider_query_selected_fetch_endpoints(
|
||||||
endpoints: &[StoredProviderCatalogEndpoint],
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
|
key: &StoredProviderCatalogKey,
|
||||||
) -> Vec<StoredProviderCatalogEndpoint> {
|
) -> 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();
|
let mut by_format = BTreeMap::<String, StoredProviderCatalogEndpoint>::new();
|
||||||
for endpoint in endpoints.iter().filter(|endpoint| endpoint.is_active) {
|
for endpoint in endpoints.iter().filter(|endpoint| endpoint.is_active) {
|
||||||
let api_format = provider_query_normalize_api_format(&endpoint.api_format);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
by_format.insert(api_format, endpoint.clone());
|
by_format.insert(api_format, endpoint.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 与 Python 版本保持一致:同族优先使用 chat 端点,其次才回退到 cli/compact。
|
// 与 Python 版本保持一致:同族优先使用 chat 端点,其次才回退到其他抓取格式。
|
||||||
PROVIDER_QUERY_FETCH_FORMAT_PRIORITY
|
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()
|
.iter()
|
||||||
.filter_map(|candidates| {
|
.filter_map(|candidates| {
|
||||||
candidates
|
candidates
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|api_format| by_format.get(*api_format))
|
.find_map(|api_format| by_format.remove(*api_format))
|
||||||
.cloned()
|
|
||||||
})
|
})
|
||||||
.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(
|
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() {
|
if selected_endpoints.is_empty() {
|
||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models: Vec::new(),
|
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 cache_hit_count = 0usize;
|
||||||
let mut fetch_count = 0usize;
|
let mut fetch_count = 0usize;
|
||||||
for key in active_keys {
|
for key in active_keys {
|
||||||
let result =
|
let result = provider_query_fetch_models_for_key(
|
||||||
provider_query_fetch_models_for_key(state.app(), &provider, &endpoints, key, force_refresh)
|
state.app(),
|
||||||
.await?;
|
&provider,
|
||||||
|
&endpoints,
|
||||||
|
key,
|
||||||
|
force_refresh,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
all_models.extend(result.models);
|
all_models.extend(result.models);
|
||||||
if let Some(error) = result.error {
|
if let Some(error) = result.error {
|
||||||
all_errors.push(format!(
|
all_errors.push(format!(
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ pub(crate) async fn build_admin_provider_keys_payload(
|
|||||||
.skip(skip)
|
.skip(skip)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map(|key| {
|
.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(),
|
.collect(),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -661,7 +661,8 @@ fn admin_provider_crud_mod_stays_thin() {
|
|||||||
|
|
||||||
let crud_routes =
|
let crud_routes =
|
||||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/crud/routes.rs");
|
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!(
|
assert!(
|
||||||
crud_routes.contains(pattern),
|
crud_routes.contains(pattern),
|
||||||
"handlers/admin/provider/crud/routes.rs should delegate through request/provider owner {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() {
|
fn admin_provider_models_own_provider_model_builders() {
|
||||||
let provider_models_mod =
|
let provider_models_mod =
|
||||||
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/models/mod.rs");
|
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/models/mod.rs");
|
||||||
for pattern in ["mod payloads;"] {
|
{
|
||||||
|
let pattern = "mod payloads;";
|
||||||
assert!(
|
assert!(
|
||||||
provider_models_mod.contains(pattern),
|
provider_models_mod.contains(pattern),
|
||||||
"handlers/admin/provider/models/mod.rs should register local provider-model owner module {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"
|
"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!(
|
assert!(
|
||||||
!workspace_file_exists(path),
|
!workspace_file_exists(path),
|
||||||
"{path} should be deleted once endpoint root dispatches directly to provider-owned handlers"
|
"{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;"),
|
!matrix.contains("mod matrix;"),
|
||||||
"planner/standard/mod.rs should not keep a local matrix wrapper module"
|
"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!(
|
assert!(
|
||||||
!matrix.contains(forbidden),
|
!matrix.contains(forbidden),
|
||||||
"planner/standard/mod.rs should not keep matrix conversion helper {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();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_provider_query_models_aggregating_active_keys() {
|
async fn gateway_handles_admin_provider_query_models_aggregating_active_keys() {
|
||||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ pub fn build_admin_monitoring_cache_provider_delete_success_response(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_admin_monitoring_trace_provider_stats_payload_response(
|
pub fn build_admin_monitoring_trace_provider_stats_payload_response(
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
total_attempts: usize,
|
total_attempts: usize,
|
||||||
@@ -313,6 +314,7 @@ pub fn build_admin_monitoring_trace_request_candidate_payload(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_admin_monitoring_system_status_payload_response(
|
pub fn build_admin_monitoring_system_status_payload_response(
|
||||||
timestamp: chrono::DateTime<chrono::Utc>,
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
total_users: u64,
|
total_users: u64,
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ pub fn admin_usage_aggregation_by_provider_json(
|
|||||||
usage: &[StoredRequestUsageAudit],
|
usage: &[StoredRequestUsageAudit],
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64, u64)> = BTreeMap::new();
|
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64, u64)> = BTreeMap::new();
|
||||||
for item in usage {
|
for item in usage {
|
||||||
let key = item
|
let key = item
|
||||||
@@ -458,6 +459,7 @@ pub fn admin_usage_aggregation_by_api_format_json(
|
|||||||
usage: &[StoredRequestUsageAudit],
|
usage: &[StoredRequestUsageAudit],
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64)> = BTreeMap::new();
|
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64)> = BTreeMap::new();
|
||||||
for item in usage {
|
for item in usage {
|
||||||
let key = item
|
let key = item
|
||||||
@@ -1069,6 +1071,7 @@ pub fn build_admin_usage_detail_payload(
|
|||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_admin_usage_replay_plan_response(
|
pub fn build_admin_usage_replay_plan_response(
|
||||||
item: &StoredRequestUsageAudit,
|
item: &StoredRequestUsageAudit,
|
||||||
target_provider: &StoredProviderCatalogProvider,
|
target_provider: &StoredProviderCatalogProvider,
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ fn trimmed_non_empty_string(value: Option<String>) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_admin_provider_endpoint_record(
|
pub fn build_admin_provider_endpoint_record(
|
||||||
id: String,
|
id: String,
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
|
|||||||
@@ -86,8 +86,7 @@ fn normalize_admin_provider_oauth_kiro_import_item(item: &Value) -> Option<Value
|
|||||||
] {
|
] {
|
||||||
if let Some(value) = object.get(key) {
|
if let Some(value) = object.get(key) {
|
||||||
if !value.is_null()
|
if !value.is_null()
|
||||||
&& !(value.is_string()
|
&& !value.as_str().is_some_and(|inner| inner.trim().is_empty())
|
||||||
&& value.as_str().is_some_and(|inner| inner.trim().is_empty()))
|
|
||||||
{
|
{
|
||||||
merged.insert(key.to_string(), value.clone());
|
merged.insert(key.to_string(), value.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -785,6 +785,7 @@ pub fn build_admin_pool_overview_payload(
|
|||||||
json!({ "items": items })
|
json!({ "items": items })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_admin_pool_batch_import_key_record(
|
pub fn build_admin_pool_batch_import_key_record(
|
||||||
id: String,
|
id: String,
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ pub struct LocalStreamPlanAndReport {
|
|||||||
pub report_context: Option<serde_json::Value>,
|
pub report_context: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_gateway_control_plan_request(
|
pub fn build_gateway_control_plan_request(
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ pub fn apply_codex_openai_cli_special_headers(
|
|||||||
provider_request_headers.insert("session_id".to_string(), short_id.clone());
|
provider_request_headers.insert("session_id".to_string(), short_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if provider_api_format.trim().to_ascii_lowercase() != "openai:compact"
|
if !provider_api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("openai:compact")
|
||||||
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
|
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
|
||||||
{
|
{
|
||||||
provider_request_headers.insert("conversation_id".to_string(), short_id);
|
provider_request_headers.insert("conversation_id".to_string(), short_id);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::conversion::request::{
|
|||||||
|
|
||||||
use super::codex::apply_codex_openai_cli_special_body_edits;
|
use super::codex::apply_codex_openai_cli_special_body_edits;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_standard_request_body(
|
pub fn build_standard_request_body(
|
||||||
body_json: &Value,
|
body_json: &Value,
|
||||||
client_api_format: &str,
|
client_api_format: &str,
|
||||||
|
|||||||
@@ -727,9 +727,7 @@ const DEFAULT_SENSITIVE_HEADERS: &[&str] = &[
|
|||||||
/// 判断 header 名是否属于敏感字段(大小写不敏感)。
|
/// 判断 header 名是否属于敏感字段(大小写不敏感)。
|
||||||
fn is_sensitive_header(name: &str) -> bool {
|
fn is_sensitive_header(name: &str) -> bool {
|
||||||
let lower = name.trim().to_ascii_lowercase();
|
let lower = name.trim().to_ascii_lowercase();
|
||||||
DEFAULT_SENSITIVE_HEADERS
|
DEFAULT_SENSITIVE_HEADERS.contains(&lower.as_str())
|
||||||
.iter()
|
|
||||||
.any(|candidate| *candidate == lower.as_str())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对单个 header value 进行脱敏:保留前 4 + 后 4 字符,中间替换为 `****`。
|
/// 对单个 header value 进行脱敏:保留前 4 + 后 4 字符,中间替换为 `****`。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
buildPoolManagementQueryPatch,
|
buildPoolManagementQueryPatch,
|
||||||
readPoolManagementViewState,
|
readPoolManagementViewState,
|
||||||
|
resolvePoolManagementPageAfterLoad,
|
||||||
writePoolManagementViewState,
|
writePoolManagementViewState,
|
||||||
} from '@/features/pool/utils/poolManagementState'
|
} from '@/features/pool/utils/poolManagementState'
|
||||||
|
|
||||||
@@ -100,4 +101,24 @@ describe('poolManagementState', () => {
|
|||||||
pageSize: undefined,
|
pageSize: undefined,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clamps a restored page to the last available page after load', () => {
|
||||||
|
expect(
|
||||||
|
resolvePoolManagementPageAfterLoad({
|
||||||
|
requestedPage: 5,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 120,
|
||||||
|
}),
|
||||||
|
).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets an out-of-range empty result page back to page 1', () => {
|
||||||
|
expect(
|
||||||
|
resolvePoolManagementPageAfterLoad({
|
||||||
|
requestedPage: 4,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 0,
|
||||||
|
}),
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,3 +127,23 @@ export function buildPoolManagementQueryPatch(
|
|||||||
: String(normalized.pageSize),
|
: String(normalized.pageSize),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePoolManagementPageAfterLoad(input: {
|
||||||
|
requestedPage: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}): number {
|
||||||
|
const requestedPage = normalizePositiveInteger(
|
||||||
|
input.requestedPage,
|
||||||
|
DEFAULT_POOL_MANAGEMENT_VIEW_STATE.page,
|
||||||
|
)
|
||||||
|
const pageSize = normalizePositiveInteger(
|
||||||
|
input.pageSize,
|
||||||
|
DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize,
|
||||||
|
)
|
||||||
|
const total = Math.max(0, Number.parseInt(String(input.total ?? 0), 10) || 0)
|
||||||
|
const lastPage =
|
||||||
|
total > 0 ? Math.ceil(total / pageSize) : DEFAULT_POOL_MANAGEMENT_VIEW_STATE.page
|
||||||
|
|
||||||
|
return Math.min(requestedPage, lastPage)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1201,6 +1201,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildPoolManagementQueryPatch,
|
buildPoolManagementQueryPatch,
|
||||||
readPoolManagementViewState,
|
readPoolManagementViewState,
|
||||||
|
resolvePoolManagementPageAfterLoad,
|
||||||
type PoolManagementViewState,
|
type PoolManagementViewState,
|
||||||
writePoolManagementViewState,
|
writePoolManagementViewState,
|
||||||
} from '@/features/pool/utils/poolManagementState'
|
} from '@/features/pool/utils/poolManagementState'
|
||||||
@@ -1790,6 +1791,15 @@ async function loadKeys() {
|
|||||||
status,
|
status,
|
||||||
})
|
})
|
||||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||||
|
const resolvedPage = resolvePoolManagementPageAfterLoad({
|
||||||
|
requestedPage: page,
|
||||||
|
pageSize: pageSizeValue,
|
||||||
|
total: nextPage.total,
|
||||||
|
})
|
||||||
|
if (resolvedPage !== page) {
|
||||||
|
currentPage.value = resolvedPage
|
||||||
|
return
|
||||||
|
}
|
||||||
keyPage.value = nextPage
|
keyPage.value = nextPage
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||||
|
|||||||
Reference in New Issue
Block a user