fix: simplify account self-check config

This commit is contained in:
fawney19
2026-05-15 02:14:46 +08:00
parent 54a8312e46
commit 8e0f8003a9
14 changed files with 75 additions and 658 deletions

View File

@@ -3,9 +3,10 @@ pub(crate) use crate::handlers::admin::{
build_internal_control_error_response, create_provider_oauth_catalog_key, build_internal_control_error_response, create_provider_oauth_catalog_key,
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response, find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
maybe_build_local_admin_response, persist_provider_quota_refresh_state, maybe_build_local_admin_response, persist_provider_quota_refresh_state,
provider_account_self_check_endpoint_for_provider,
provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider, provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider,
provider_quota_refresh_endpoint_for_provider, provider_type_supports_quota_refresh, provider_quota_refresh_endpoint_for_provider, provider_type_supports_account_self_check,
reconcile_admin_fixed_provider_template_endpoints, provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints,
refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally, refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
update_existing_provider_oauth_catalog_key, AdminAppState, update_existing_provider_oauth_catalog_key, AdminAppState,
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,

View File

@@ -27,7 +27,8 @@ pub(crate) use self::provider::oauth::provisioning::{
}; };
pub(crate) use self::provider::oauth::quota::dispatch::refresh_provider_pool_quota_locally; pub(crate) use self::provider::oauth::quota::dispatch::refresh_provider_pool_quota_locally;
pub(crate) use self::provider::oauth::quota::shared::{ pub(crate) use self::provider::oauth::quota::shared::{
persist_provider_quota_refresh_state, provider_quota_refresh_endpoint_for_provider, persist_provider_quota_refresh_state, provider_account_self_check_endpoint_for_provider,
provider_quota_refresh_endpoint_for_provider, provider_type_supports_account_self_check,
provider_type_supports_quota_refresh, provider_type_supports_quota_refresh,
}; };
pub(crate) use self::provider::oauth::runtime::{ pub(crate) use self::provider::oauth::runtime::{

View File

@@ -76,6 +76,22 @@ pub(crate) fn provider_quota_refresh_endpoint_for_provider(
) )
} }
pub(crate) fn provider_type_supports_account_self_check(provider_type: &str) -> bool {
ProviderPoolService::with_builtin_adapters().supports_account_self_check(provider_type)
}
pub(crate) fn provider_account_self_check_endpoint_for_provider(
provider_type: &str,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
ProviderPoolService::with_builtin_adapters().account_self_check_endpoint_for_provider(
provider_type,
endpoints,
include_inactive,
)
}
pub(crate) fn provider_quota_refresh_missing_endpoint_message(provider_type: &str) -> String { pub(crate) fn provider_quota_refresh_missing_endpoint_message(provider_type: &str) -> String {
ProviderPoolService::with_builtin_adapters() ProviderPoolService::with_builtin_adapters()
.quota_refresh_missing_endpoint_message(provider_type) .quota_refresh_missing_endpoint_message(provider_type)

View File

@@ -57,18 +57,6 @@ fn parse_pool_probe_target_count(pool_advanced: &Map<String, Value>) -> Option<u
.map(|value| value.min(100_000)) .map(|value| value.min(100_000))
} }
fn parse_pool_account_self_check_method(pool_advanced: &Map<String, Value>) -> String {
pool_advanced
.get("account_self_check_method")
.or_else(|| pool_advanced.get("self_check_method"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
.filter(|value| matches!(value.as_str(), "quota_refresh" | "custom_request"))
.unwrap_or_else(|| "quota_refresh".to_string())
}
fn pool_score_weight(object: &Map<String, Value>, names: &[&str], current: f64) -> f64 { fn pool_score_weight(object: &Map<String, Value>, names: &[&str], current: f64) -> f64 {
names names
.iter() .iter()
@@ -426,8 +414,6 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
account_self_check_enabled: false, account_self_check_enabled: false,
account_self_check_interval_minutes: 60, account_self_check_interval_minutes: 60,
account_self_check_concurrency: 4, account_self_check_concurrency: 4,
account_self_check_method: "quota_refresh".to_string(),
account_self_check_request: None,
score_top_n: 128, score_top_n: 128,
score_fallback_scan_limit: 1024, score_fallback_scan_limit: 1024,
score_rules: PoolMemberScoreRules::default(), score_rules: PoolMemberScoreRules::default(),
@@ -522,12 +508,6 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
.filter(|value| *value > 0) .filter(|value| *value > 0)
.map(|value| value.min(64)) .map(|value| value.min(64))
.unwrap_or(4), .unwrap_or(4),
account_self_check_method: parse_pool_account_self_check_method(pool_advanced),
account_self_check_request: pool_advanced
.get("account_self_check_request")
.or_else(|| pool_advanced.get("self_check_request"))
.filter(|value| value.is_object())
.cloned(),
score_top_n: pool_advanced score_top_n: pool_advanced
.get("score_top_n") .get("score_top_n")
.and_then(json_u64) .and_then(json_u64)
@@ -619,12 +599,6 @@ mod tests {
"account_self_check_enabled": true, "account_self_check_enabled": true,
"account_self_check_interval_minutes": 90, "account_self_check_interval_minutes": 90,
"account_self_check_concurrency": 5, "account_self_check_concurrency": 5,
"account_self_check_method": "custom_request",
"account_self_check_request": {
"method": "GET",
"path": "/v1/me",
"success_status_codes": [200]
},
"score_top_n": 256, "score_top_n": 256,
"score_fallback_scan_limit": 2048, "score_fallback_scan_limit": 2048,
"score_rules": { "score_rules": {
@@ -667,15 +641,6 @@ mod tests {
assert!(config.account_self_check_enabled); assert!(config.account_self_check_enabled);
assert_eq!(config.account_self_check_interval_minutes, 90); assert_eq!(config.account_self_check_interval_minutes, 90);
assert_eq!(config.account_self_check_concurrency, 5); assert_eq!(config.account_self_check_concurrency, 5);
assert_eq!(config.account_self_check_method, "custom_request");
assert_eq!(
config
.account_self_check_request
.as_ref()
.and_then(|value| value.get("path"))
.and_then(serde_json::Value::as_str),
Some("/v1/me")
);
assert_eq!(config.score_top_n, 256); assert_eq!(config.score_top_n, 256);
assert_eq!(config.score_fallback_scan_limit, 2048); assert_eq!(config.score_fallback_scan_limit, 2048);
assert_eq!(config.score_rules.weights.manual_priority, 0.4); assert_eq!(config.score_rules.weights.manual_priority, 0.4);

View File

@@ -621,8 +621,6 @@ mod tests {
account_self_check_enabled: false, account_self_check_enabled: false,
account_self_check_interval_minutes: 60, account_self_check_interval_minutes: 60,
account_self_check_concurrency: 4, account_self_check_concurrency: 4,
account_self_check_method: "quota_refresh".to_string(),
account_self_check_request: None,
score_top_n: 128, score_top_n: 128,
score_fallback_scan_limit: 1024, score_fallback_scan_limit: 1024,
score_rules: aether_pool_core::PoolMemberScoreRules::default(), score_rules: aether_pool_core::PoolMemberScoreRules::default(),

View File

@@ -52,8 +52,6 @@ pub(crate) struct AdminProviderPoolConfig {
pub(crate) account_self_check_enabled: bool, pub(crate) account_self_check_enabled: bool,
pub(crate) account_self_check_interval_minutes: u64, pub(crate) account_self_check_interval_minutes: u64,
pub(crate) account_self_check_concurrency: u64, pub(crate) account_self_check_concurrency: u64,
pub(crate) account_self_check_method: String,
pub(crate) account_self_check_request: Option<serde_json::Value>,
pub(crate) score_top_n: u64, pub(crate) score_top_n: u64,
pub(crate) score_fallback_scan_limit: u64, pub(crate) score_fallback_scan_limit: u64,
pub(crate) score_rules: PoolMemberScoreRules, pub(crate) score_rules: PoolMemberScoreRules,

View File

@@ -1,7 +1,6 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::BTreeMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aether_contracts::{ExecutionPlan, ExecutionResult, RequestBody};
use aether_data_contracts::repository::pool_scores::{ use aether_data_contracts::repository::pool_scores::{
PoolMemberHardState, PoolMemberIdentity, PoolMemberProbeAttempt, PoolMemberProbeResult, PoolMemberHardState, PoolMemberIdentity, PoolMemberProbeAttempt, PoolMemberProbeResult,
PoolMemberProbeStatus, PoolMemberProbeStatus,
@@ -10,17 +9,13 @@ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use aether_runtime_state::{RuntimeLockLease, RuntimeState}; use aether_runtime_state::{RuntimeLockLease, RuntimeState};
use base64::Engine as _;
use futures_util::{stream, StreamExt}; use futures_util::{stream, StreamExt};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::admin_api::{ use crate::admin_api::{
admin_provider_pool_config, persist_provider_quota_refresh_state, admin_provider_pool_config, provider_account_self_check_endpoint_for_provider,
provider_quota_refresh_endpoint_for_provider, provider_type_supports_quota_refresh, provider_type_supports_account_self_check, refresh_provider_pool_quota_locally, AdminAppState,
refresh_provider_pool_quota_locally, AdminAppState, AdminGatewayProviderTransportSnapshot,
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
}; };
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
@@ -94,37 +89,6 @@ impl AccountSelfCheckWorkerConfig {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AccountSelfCheckRequestConfig {
pub(crate) method: String,
pub(crate) url: Option<String>,
pub(crate) path: Option<String>,
pub(crate) headers: BTreeMap<String, String>,
pub(crate) json_body: Option<Value>,
pub(crate) body: Option<String>,
pub(crate) body_bytes_b64: Option<String>,
pub(crate) content_type: Option<String>,
pub(crate) success_status_codes: BTreeSet<u16>,
pub(crate) blocked_status_codes: BTreeSet<u16>,
}
impl Default for AccountSelfCheckRequestConfig {
fn default() -> Self {
Self {
method: "GET".to_string(),
url: None,
path: None,
headers: BTreeMap::new(),
json_body: None,
body: None,
body_bytes_b64: None,
content_type: None,
success_status_codes: BTreeSet::from([200]),
blocked_status_codes: BTreeSet::from([401, 403, 423]),
}
}
}
enum AccountSelfCheckOutcome { enum AccountSelfCheckOutcome {
Success { Success {
status_code: Option<u16>, status_code: Option<u16>,
@@ -388,314 +352,6 @@ async fn select_keys_for_provider(
result result
} }
fn normalize_http_method(raw: Option<&Value>) -> String {
let method = raw
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("GET")
.to_ascii_uppercase();
match method.as_str() {
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" => method,
_ => "GET".to_string(),
}
}
fn json_string(raw: Option<&Value>) -> Option<String> {
raw.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn parse_status_codes(raw: Option<&Value>, fallback: &[u16]) -> BTreeSet<u16> {
let mut out = BTreeSet::new();
if let Some(array) = raw.and_then(Value::as_array) {
for item in array {
if let Some(value) = item
.as_u64()
.and_then(|value| u16::try_from(value).ok())
.filter(|value| (100..=599).contains(value))
{
out.insert(value);
}
}
}
if out.is_empty() {
out.extend(fallback.iter().copied());
}
out
}
fn parse_headers(raw: Option<&Value>) -> BTreeMap<String, String> {
let Some(object) = raw.and_then(Value::as_object) else {
return BTreeMap::new();
};
object
.iter()
.filter_map(|(key, value)| {
let key = key.trim().to_ascii_lowercase();
let value = match value {
Value::String(value) => value.trim().to_string(),
_ => value.to_string(),
};
(!key.is_empty()).then_some((key, value))
})
.collect()
}
pub(crate) fn parse_account_self_check_request_config(
raw: Option<&Value>,
) -> AccountSelfCheckRequestConfig {
let Some(object) = raw.and_then(Value::as_object) else {
return AccountSelfCheckRequestConfig::default();
};
AccountSelfCheckRequestConfig {
method: normalize_http_method(object.get("method")),
url: json_string(object.get("url")),
path: json_string(object.get("path")),
headers: parse_headers(object.get("headers")),
json_body: object
.get("json_body")
.or_else(|| object.get("json"))
.or_else(|| object.get("body_json"))
.cloned(),
body: json_string(object.get("body")),
body_bytes_b64: json_string(
object
.get("body_bytes_b64")
.or_else(|| object.get("body_base64")),
),
content_type: json_string(object.get("content_type")),
success_status_codes: parse_status_codes(object.get("success_status_codes"), &[200]),
blocked_status_codes: parse_status_codes(
object
.get("blocked_status_codes")
.or_else(|| object.get("banned_status_codes")),
&[401, 403, 423],
),
}
}
fn custom_check_url(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
request_config: &AccountSelfCheckRequestConfig,
) -> Option<String> {
if let Some(url) = request_config.url.as_deref() {
let parsed = url::Url::parse(url).ok()?;
if !matches!(parsed.scheme(), "http" | "https") {
return None;
}
return Some(url.to_string());
}
let path = request_config.path.as_deref()?;
state.build_passthrough_path_url(&transport.endpoint.base_url, path, None, &["key"])
}
async fn resolve_self_check_auth(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
) -> Result<Option<(String, String)>, GatewayError> {
if let Some(auth) = state.resolve_local_oauth_header_auth(transport).await? {
return Ok(Some(auth));
}
if let Some(auth) = crate::provider_transport::auth::resolve_local_openai_bearer_auth(transport)
{
return Ok(Some(auth));
}
if let Some(auth) = crate::provider_transport::auth::resolve_local_standard_auth(transport) {
return Ok(Some(auth));
}
Ok(state.resolve_local_gemini_auth(transport))
}
fn custom_check_body(request_config: &AccountSelfCheckRequestConfig) -> RequestBody {
if let Some(json_body) = request_config.json_body.clone() {
return RequestBody::from_json(json_body);
}
if let Some(body_bytes_b64) = request_config.body_bytes_b64.as_ref() {
return RequestBody {
json_body: None,
body_bytes_b64: Some(body_bytes_b64.clone()),
body_ref: None,
};
}
if let Some(body) = request_config.body.as_ref() {
return RequestBody {
json_body: None,
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body.as_bytes())),
body_ref: None,
};
}
RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
}
}
fn build_custom_check_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
request_config: &AccountSelfCheckRequestConfig,
url: String,
auth: Option<(String, String)>,
proxy: Option<aether_contracts::ProxySnapshot>,
) -> ExecutionPlan {
let mut headers = request_config.headers.clone();
if let Some((auth_header, auth_value)) = auth {
crate::provider_transport::ensure_upstream_auth_header(
&mut headers,
&auth_header,
&auth_value,
);
}
let content_type = request_config.content_type.clone().or_else(|| {
request_config
.json_body
.is_some()
.then(|| "application/json".to_string())
});
if let Some(content_type) = content_type.as_ref() {
headers
.entry("content-type".to_string())
.or_insert_with(|| content_type.clone());
}
ExecutionPlan {
request_id: format!("account-self-check-{}", Uuid::new_v4()),
candidate_id: None,
provider_name: Some(transport.provider.name.clone()),
provider_id: transport.provider.id.clone(),
endpoint_id: transport.endpoint.id.clone(),
key_id: transport.key.id.clone(),
method: request_config.method.clone(),
url,
headers,
content_type,
content_encoding: None,
body: custom_check_body(request_config),
stream: false,
client_api_format: transport.endpoint.api_format.clone(),
provider_api_format: transport.endpoint.api_format.clone(),
model_name: None,
proxy,
transport_profile: state.resolve_transport_profile(transport),
timeouts: state.resolve_transport_execution_timeouts(transport),
}
}
fn execution_error_message(result: &ExecutionResult) -> Option<String> {
if let Some(body_json) = result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
.and_then(Value::as_object)
{
if let Some(error) = body_json.get("error") {
if let Some(message) = error
.get("message")
.or_else(|| error.get("error_description"))
.and_then(Value::as_str)
{
let trimmed = message.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
if let Some(text) = error
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(text.to_string());
}
}
if let Some(message) = body_json
.get("message")
.or_else(|| body_json.get("error_description"))
.and_then(Value::as_str)
{
let trimmed = message.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
result
.error
.as_ref()
.map(|error| error.message.trim().to_string())
.filter(|value| !value.is_empty())
}
fn custom_result_to_outcome(
result: ExecutionResult,
request_config: &AccountSelfCheckRequestConfig,
) -> AccountSelfCheckOutcome {
let message = execution_error_message(&result);
if request_config
.success_status_codes
.contains(&result.status_code)
{
return AccountSelfCheckOutcome::Success {
status_code: Some(result.status_code),
message,
};
}
if request_config
.blocked_status_codes
.contains(&result.status_code)
{
let detail = message.unwrap_or_else(|| format!("HTTP {}", result.status_code));
return AccountSelfCheckOutcome::Blocked {
status_code: Some(result.status_code),
message: detail,
};
}
let detail = message.unwrap_or_else(|| format!("HTTP {}", result.status_code));
AccountSelfCheckOutcome::Failed {
status_code: Some(result.status_code),
message: detail,
}
}
async fn perform_custom_request_check(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
endpoint: &StoredProviderCatalogEndpoint,
key: &StoredProviderCatalogKey,
request_config: &AccountSelfCheckRequestConfig,
) -> Result<AccountSelfCheckOutcome, GatewayError> {
let Some(transport) = state
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
.await?
else {
return Ok(AccountSelfCheckOutcome::Skipped {
message: "Provider transport snapshot unavailable".to_string(),
});
};
let Some(url) = custom_check_url(state, &transport, request_config) else {
return Ok(AccountSelfCheckOutcome::Skipped {
message: "account_self_check_request missing valid url/path".to_string(),
});
};
let auth = resolve_self_check_auth(state, &transport).await?;
let proxy = state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
.await;
let plan = build_custom_check_plan(state, &transport, request_config, url, auth, proxy);
match state.execute_execution_runtime_sync_plan(None, &plan).await {
Ok(result) => Ok(custom_result_to_outcome(result, request_config)),
Err(err) => Ok(AccountSelfCheckOutcome::Failed {
status_code: None,
message: gateway_error_message(err),
}),
}
}
fn quota_payload_result_for_key(key_id: &str, payload: Option<Value>) -> AccountSelfCheckOutcome { fn quota_payload_result_for_key(key_id: &str, payload: Option<Value>) -> AccountSelfCheckOutcome {
let Some(payload) = payload else { let Some(payload) = payload else {
return AccountSelfCheckOutcome::Failed { return AccountSelfCheckOutcome::Failed {
@@ -787,97 +443,6 @@ async fn perform_quota_refresh_check(
Ok(quota_payload_result_for_key(&key_id, payload)) Ok(quota_payload_result_for_key(&key_id, payload))
} }
fn account_block_reason(message: &str) -> String {
let detail = message.trim();
if detail.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) {
detail.to_string()
} else {
format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}")
}
}
fn request_failure_reason(message: &str) -> String {
let detail = message.trim();
if detail.starts_with(OAUTH_REQUEST_FAILED_PREFIX) {
detail.to_string()
} else {
format!("{OAUTH_REQUEST_FAILED_PREFIX}{detail}")
}
}
fn success_invalid_state(key: &StoredProviderCatalogKey) -> (Option<u64>, Option<String>) {
let current_reason = key
.oauth_invalid_reason
.as_deref()
.map(str::trim)
.unwrap_or_default();
if current_reason.starts_with("[REFRESH_FAILED] ") {
return (
key.oauth_invalid_at_unix_secs,
(!current_reason.is_empty()).then_some(current_reason.to_string()),
);
}
(None, None)
}
async fn persist_custom_check_outcome(
state: &AdminAppState<'_>,
key: &StoredProviderCatalogKey,
outcome: &AccountSelfCheckOutcome,
now_ts: u64,
) -> Result<bool, GatewayError> {
match outcome {
AccountSelfCheckOutcome::Success { .. } => {
let (invalid_at, invalid_reason) = success_invalid_state(key);
persist_provider_quota_refresh_state(
state,
&key.id,
None,
invalid_at,
invalid_reason,
None,
)
.await
}
AccountSelfCheckOutcome::Blocked { message, .. } => {
persist_provider_quota_refresh_state(
state,
&key.id,
None,
Some(now_ts),
Some(account_block_reason(message)),
None,
)
.await
}
AccountSelfCheckOutcome::Failed { message, .. } => {
persist_provider_quota_refresh_state(
state,
&key.id,
None,
Some(now_ts),
Some(request_failure_reason(message)),
None,
)
.await
}
AccountSelfCheckOutcome::Skipped { .. } => Ok(false),
}
}
async fn persist_self_check_outcome(
state: &AdminAppState<'_>,
method: &str,
key: &StoredProviderCatalogKey,
outcome: &AccountSelfCheckOutcome,
now_ts: u64,
) -> Result<bool, GatewayError> {
if method == "custom_request" {
return persist_custom_check_outcome(state, key, outcome, now_ts).await;
}
Ok(!matches!(outcome, AccountSelfCheckOutcome::Skipped { .. }))
}
async fn record_score_probe_in_progress_for_key( async fn record_score_probe_in_progress_for_key(
state: &AppState, state: &AppState,
provider_id: &str, provider_id: &str,
@@ -982,14 +547,7 @@ fn endpoint_for_self_check(
provider_type: &str, provider_type: &str,
endpoints: &[StoredProviderCatalogEndpoint], endpoints: &[StoredProviderCatalogEndpoint],
) -> Option<StoredProviderCatalogEndpoint> { ) -> Option<StoredProviderCatalogEndpoint> {
provider_quota_refresh_endpoint_for_provider(provider_type, endpoints, true) provider_account_self_check_endpoint_for_provider(provider_type, endpoints, true)
.or_else(|| {
endpoints
.iter()
.find(|endpoint| endpoint.is_active)
.cloned()
})
.or_else(|| endpoints.first().cloned())
} }
fn gateway_error_message(err: GatewayError) -> String { fn gateway_error_message(err: GatewayError) -> String {
@@ -1079,9 +637,7 @@ pub(crate) async fn perform_account_self_check_once_with_config(
summary.providers_skipped = summary.providers_skipped.saturating_add(1); summary.providers_skipped = summary.providers_skipped.saturating_add(1);
continue; continue;
}; };
if pool_config.account_self_check_method == "quota_refresh" if !provider_type_supports_account_self_check(&provider_type) {
&& !provider_type_supports_quota_refresh(&provider_type)
{
summary.providers_skipped = summary.providers_skipped.saturating_add(1); summary.providers_skipped = summary.providers_skipped.saturating_add(1);
continue; continue;
} }
@@ -1112,10 +668,6 @@ pub(crate) async fn perform_account_self_check_once_with_config(
record_score_probe_in_progress_for_key(state, &provider.id, &key.id, now_ts).await; record_score_probe_in_progress_for_key(state, &provider.id, &key.id, now_ts).await;
} }
let method = pool_config.account_self_check_method.clone();
let request_config = parse_account_self_check_request_config(
pool_config.account_self_check_request.as_ref(),
);
let provider_short_id = provider.id.chars().take(8).collect::<String>(); let provider_short_id = provider.id.chars().take(8).collect::<String>();
let concurrency = (pool_config.account_self_check_concurrency as usize) let concurrency = (pool_config.account_self_check_concurrency as usize)
.clamp(1, 64) .clamp(1, 64)
@@ -1126,29 +678,16 @@ pub(crate) async fn perform_account_self_check_once_with_config(
let provider = &provider; let provider = &provider;
let endpoint = &endpoint; let endpoint = &endpoint;
let provider_type = provider_type.as_str(); let provider_type = provider_type.as_str();
let method = method.as_str();
let request_config = &request_config;
async move { async move {
let key_for_check = key.clone(); let key_for_check = key.clone();
let result = if method == "custom_request" { let result = perform_quota_refresh_check(
perform_custom_request_check( admin_state,
admin_state, provider,
provider, endpoint,
endpoint, provider_type,
&key_for_check, key_for_check,
request_config, )
) .await;
.await
} else {
perform_quota_refresh_check(
admin_state,
provider,
endpoint,
provider_type,
key_for_check,
)
.await
};
(key, result) (key, result)
} }
})) }))
@@ -1164,15 +703,6 @@ pub(crate) async fn perform_account_self_check_once_with_config(
message: gateway_error_message(err), message: gateway_error_message(err),
}, },
}; };
let persisted =
persist_self_check_outcome(&admin_state, &method, &key, &outcome, now_ts).await?;
if !persisted && !matches!(outcome, AccountSelfCheckOutcome::Skipped { .. }) {
warn!(
provider_id = %provider.id,
key_id = %key.id,
"gateway account self-check: key state was not updated"
);
}
record_score_probe_result_for_key(state, &provider.id, &key.id, now_ts, &outcome).await; record_score_probe_result_for_key(state, &provider.id, &key.id, now_ts, &outcome).await;
update_summary_from_outcome(&mut summary, &outcome); update_summary_from_outcome(&mut summary, &outcome);
} }
@@ -1222,12 +752,8 @@ pub(crate) fn spawn_account_self_check_worker(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::select_account_self_check_key_ids;
parse_account_self_check_request_config, select_account_self_check_key_ids, use std::collections::BTreeMap;
AccountSelfCheckRequestConfig,
};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
#[test] #[test]
fn selects_never_and_stale_self_check_keys_first() { fn selects_never_and_stale_self_check_keys_first() {
@@ -1242,37 +768,4 @@ mod tests {
assert_eq!(selected, vec!["never".to_string(), "stale".to_string()]); assert_eq!(selected, vec!["never".to_string(), "stale".to_string()]);
} }
#[test]
fn parses_custom_self_check_request_config() {
let parsed = parse_account_self_check_request_config(Some(&json!({
"method": "post",
"path": "/v1/me?trace=1",
"headers": {"X-Test": "yes"},
"json_body": {"ping": true},
"success_status_codes": [200, 204],
"blocked_status_codes": [401, 403, 423, 451]
})));
assert_eq!(parsed.method, "POST");
assert_eq!(parsed.path.as_deref(), Some("/v1/me?trace=1"));
assert_eq!(
parsed.headers.get("x-test").map(String::as_str),
Some("yes")
);
assert_eq!(parsed.json_body, Some(json!({"ping": true})));
assert_eq!(parsed.success_status_codes, BTreeSet::from([200, 204]));
assert_eq!(
parsed.blocked_status_codes,
BTreeSet::from([401, 403, 423, 451])
);
}
#[test]
fn defaults_custom_self_check_request_config() {
assert_eq!(
parse_account_self_check_request_config(None),
AccountSelfCheckRequestConfig::default()
);
}
} }

View File

@@ -87,6 +87,8 @@ mod tests {
assert!(service.supports_quota_refresh("codex")); assert!(service.supports_quota_refresh("codex"));
assert!(service.supports_quota_refresh("antigravity")); assert!(service.supports_quota_refresh("antigravity"));
assert!(!service.supports_quota_refresh("gemini_cli")); assert!(!service.supports_quota_refresh("gemini_cli"));
assert!(service.supports_account_self_check("codex"));
assert!(!service.supports_account_self_check("gemini_cli"));
assert_eq!( assert_eq!(
service.quota_refresh_unsupported_message("claude_code"), service.quota_refresh_unsupported_message("claude_code"),
"Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口" "Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口"

View File

@@ -53,6 +53,21 @@ pub trait ProviderPoolAdapter: Send + Sync {
"找不到有效端点".to_string() "找不到有效端点".to_string()
} }
fn supports_account_self_check(&self) -> bool {
self.supports_quota_refresh()
}
fn account_self_check_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
if !self.supports_account_self_check() {
return None;
}
self.quota_refresh_endpoint(endpoints, include_inactive)
}
fn normalize_plan_tier(&self, value: &str) -> Option<String> { fn normalize_plan_tier(&self, value: &str) -> Option<String> {
normalize_provider_plan_tier(value, self.provider_type()) normalize_provider_plan_tier(value, self.provider_type())
} }

View File

@@ -104,6 +104,20 @@ impl ProviderPoolService {
.quota_refresh_missing_endpoint_message() .quota_refresh_missing_endpoint_message()
} }
pub fn supports_account_self_check(&self, provider_type: &str) -> bool {
self.adapter(provider_type).supports_account_self_check()
}
pub fn account_self_check_endpoint_for_provider(
&self,
provider_type: &str,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
self.adapter(provider_type)
.account_self_check_endpoint(endpoints, include_inactive)
}
pub fn normalize_scheduling_presets( pub fn normalize_scheduling_presets(
&self, &self,
provider_type: &str, provider_type: &str,

View File

@@ -591,8 +591,6 @@ export interface PoolAdvancedConfig {
account_self_check_enabled?: boolean account_self_check_enabled?: boolean
account_self_check_interval_minutes?: number | null account_self_check_interval_minutes?: number | null
account_self_check_concurrency?: number | null account_self_check_concurrency?: number | null
account_self_check_method?: 'quota_refresh' | 'custom_request' | string
account_self_check_request?: Record<string, unknown> | null
auto_remove_banned_keys?: boolean auto_remove_banned_keys?: boolean
} }

View File

@@ -93,7 +93,7 @@
v-if="form.account_self_check_enabled" v-if="form.account_self_check_enabled"
class="space-y-3 rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4" class="space-y-3 rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
> >
<div class="grid gap-3 sm:grid-cols-3"> <div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label> <Label>
自检间隔 自检间隔
@@ -121,40 +121,6 @@
@update:model-value="(v) => form.account_self_check_concurrency = parseNum(v)" @update:model-value="(v) => form.account_self_check_concurrency = parseNum(v)"
/> />
</div> </div>
<div class="space-y-1.5">
<Label>
自检方式
</Label>
<div class="flex w-fit gap-0.5 rounded-md bg-muted/40 p-0.5">
<button
v-for="opt in accountSelfCheckMethodOptions"
:key="opt.value"
type="button"
class="rounded px-2.5 py-1 text-xs font-medium transition-all"
:class="[
form.account_self_check_method === opt.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/50 hover:text-foreground'
]"
@click="form.account_self_check_method = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<div
v-if="form.account_self_check_method === 'custom_request'"
class="space-y-1.5"
>
<Label>请求配置</Label>
<Textarea
v-model="form.account_self_check_request_text"
class="min-h-[160px] font-mono text-xs leading-5"
spellcheck="false"
placeholder='{"path":"/v1/models","success_status_codes":[200],"blocked_status_codes":[401,403]}'
/>
</div> </div>
</div> </div>
@@ -675,7 +641,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { CircleHelp } from 'lucide-vue-next' import { CircleHelp } from 'lucide-vue-next'
import { Dialog, Button, Input, Label, Switch, Textarea, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui' import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints' import { updateProvider } from '@/api/endpoints'
@@ -716,12 +682,6 @@ const healthToggleCards = buildPoolHealthToggleCards()
const cooldownFieldLayout = buildPoolCooldownFieldLayout() const cooldownFieldLayout = buildPoolCooldownFieldLayout()
const costFieldLayout = buildPoolCostFieldLayout() const costFieldLayout = buildPoolCostFieldLayout()
const secondarySectionLayout = buildPoolSecondarySectionLayout() const secondarySectionLayout = buildPoolSecondarySectionLayout()
const accountSelfCheckMethodOptions = [
{ value: 'quota_refresh', label: '刷新额度' },
{ value: 'custom_request', label: '自定义请求' },
] as const
type AccountSelfCheckMethod = typeof accountSelfCheckMethodOptions[number]['value']
const form = ref({ const form = ref({
global_priority: null as number | null | undefined, global_priority: null as number | null | undefined,
@@ -752,8 +712,6 @@ const form = ref({
account_self_check_enabled: false, account_self_check_enabled: false,
account_self_check_interval_minutes: null as number | null | undefined, account_self_check_interval_minutes: null as number | null | undefined,
account_self_check_concurrency: null as number | null | undefined, account_self_check_concurrency: null as number | null | undefined,
account_self_check_method: 'quota_refresh' as AccountSelfCheckMethod,
account_self_check_request_text: '',
auto_remove_banned_keys: false, auto_remove_banned_keys: false,
skip_exhausted_accounts: false, skip_exhausted_accounts: false,
}) })
@@ -784,37 +742,6 @@ function parseNum(v: string | number): number | undefined {
return Number.isNaN(n) ? undefined : n return Number.isNaN(n) ? undefined : n
} }
function normalizeAccountSelfCheckMethod(value: unknown): AccountSelfCheckMethod {
return value === 'custom_request' ? 'custom_request' : 'quota_refresh'
}
function formatJsonForTextarea(value: unknown): string {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return ''
}
return JSON.stringify(value, null, 2)
}
function parseJsonObjectText(text: string): Record<string, unknown> | undefined | null {
const trimmed = text.trim()
if (!trimmed) return undefined
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
showError('账号自检请求 JSON 格式不正确')
return null
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
showError('账号自检请求必须是 JSON 对象')
return null
}
return parsed as Record<string, unknown>
}
function getHealthToggleValue(key: PoolHealthToggleKey): boolean { function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
switch (key) { switch (key) {
case 'health_policy_enabled': case 'health_policy_enabled':
@@ -884,8 +811,6 @@ watch(() => props.modelValue, (open) => {
account_self_check_enabled: cfg?.account_self_check_enabled ?? false, account_self_check_enabled: cfg?.account_self_check_enabled ?? false,
account_self_check_interval_minutes: cfg?.account_self_check_interval_minutes ?? null, account_self_check_interval_minutes: cfg?.account_self_check_interval_minutes ?? null,
account_self_check_concurrency: cfg?.account_self_check_concurrency ?? null, account_self_check_concurrency: cfg?.account_self_check_concurrency ?? null,
account_self_check_method: normalizeAccountSelfCheckMethod(cfg?.account_self_check_method),
account_self_check_request_text: formatJsonForTextarea(cfg?.account_self_check_request),
auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false, auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false,
skip_exhausted_accounts: cfg?.skip_exhausted_accounts ?? false, skip_exhausted_accounts: cfg?.skip_exhausted_accounts ?? false,
} }
@@ -905,12 +830,6 @@ watch(() => props.modelValue, (open) => {
async function handleSave() { async function handleSave() {
loading.value = true loading.value = true
try { try {
const accountSelfCheckRequest = form.value.account_self_check_enabled
&& form.value.account_self_check_method === 'custom_request'
? parseJsonObjectText(form.value.account_self_check_request_text)
: undefined
if (accountSelfCheckRequest === null) return
const scoreRules = { const scoreRules = {
...(props.currentConfig?.score_rules ?? {}), ...(props.currentConfig?.score_rules ?? {}),
weights: { weights: {
@@ -936,6 +855,10 @@ async function handleSave() {
'probing_active_target_count', 'probing_active_target_count',
'active_probe_target_percent', 'active_probe_target_percent',
'active_probe_target_count', 'active_probe_target_count',
'account_self_check_method',
'self_check_method',
'account_self_check_request',
'self_check_request',
]) { ]) {
delete existingPoolAdvanced[key] delete existingPoolAdvanced[key]
} }
@@ -966,13 +889,6 @@ async function handleSave() {
account_self_check_concurrency: form.value.account_self_check_enabled account_self_check_concurrency: form.value.account_self_check_enabled
? (form.value.account_self_check_concurrency ?? undefined) ? (form.value.account_self_check_concurrency ?? undefined)
: undefined, : undefined,
account_self_check_method: form.value.account_self_check_enabled
? form.value.account_self_check_method
: undefined,
account_self_check_request: form.value.account_self_check_enabled
&& form.value.account_self_check_method === 'custom_request'
? accountSelfCheckRequest
: undefined,
auto_remove_banned_keys: form.value.auto_remove_banned_keys, auto_remove_banned_keys: form.value.auto_remove_banned_keys,
skip_exhausted_accounts: form.value.skip_exhausted_accounts, skip_exhausted_accounts: form.value.skip_exhausted_accounts,
} }

View File

@@ -33,7 +33,7 @@ describe('poolAdvancedDialog', () => {
{ {
key: 'account_self_check_enabled', key: 'account_self_check_enabled',
label: '账号自检', label: '账号自检',
description: '定时确认号状态,默认刷新额度,也可使用自定义请求。', description: '定时确认号状态,策略由提供商适配器内置。',
}, },
{ {
key: 'auto_remove_banned_keys', key: 'auto_remove_banned_keys',

View File

@@ -40,7 +40,7 @@ export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
{ {
key: 'account_self_check_enabled', key: 'account_self_check_enabled',
label: '账号自检', label: '账号自检',
description: '定时确认号状态,默认刷新额度,也可使用自定义请求。', description: '定时确认号状态,策略由提供商适配器内置。',
}, },
{ {
key: 'auto_remove_banned_keys', key: 'auto_remove_banned_keys',