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:
fawney19
2026-04-09 13:55:18 +08:00
parent b0b40c16ff
commit fe81eafe2c
24 changed files with 380 additions and 37 deletions

View File

@@ -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"));
} }

View File

@@ -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,
}) })

View File

@@ -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],

View File

@@ -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 =

View File

@@ -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(

View File

@@ -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;

View File

@@ -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!(

View File

@@ -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(),
)) ))

View File

@@ -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}"

View File

@@ -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"

View File

@@ -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}"

View File

@@ -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));

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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());
} }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 字符,中间替换为 `****`。

View File

@@ -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)
})
}) })

View File

@@ -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)
}

View File

@@ -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