Fix/api key concurrency runtime miss (#309)

* test(cli): 覆盖 API key 并发等待与超时路径

* feat(scheduler): API key 并发饱和时等待可用槽位

* fix(proxy): 区分 API key 并发受限与真正的 runtime miss

* fix(outcome): runtime miss 仅归因真实执行候选

* feat(api-keys): 统一 concurrent_limit 默认值与校验辅助

* feat(admin): 独立 Key 接口支持 concurrent_limit

* feat(admin): 用户 API Key 路由支持 concurrent_limit

* feat(public): 自助 API Key 路由支持 concurrent_limit

* feat(import): 导入与存储层持久化 concurrent_limit

* feat(frontend): 同步 API Key concurrent_limit 类型定义

* feat(frontend): 独立 Key 表单支持 concurrent_limit

* feat(frontend): 管理员用户 API Key 表单支持 concurrent_limit

* feat(frontend): 自助 API Key 页面支持 concurrent_limit

* chore(fmt): 统一 runtime 归因相关 Rust 格式

* chore(fmt): 统一 admin API key 路由 Rust 格式

* chore(fmt): 统一 public 路由与相关测试 Rust 格式

* fix(test): 对齐 no-execution usage 归因断言

* test(middleware): 固定 access log tracing 用例线程模型

* fix(frontend): 提取用户 API Key payload 默认并发辅助

* fix(frontend): 保留用户 Key 的 concurrent_limit 默认值

* fix(api-keys): remove hardcoded concurrent limit default

---------

Co-authored-by: fawney19 <elky0401@gmail.com>
This commit is contained in:
RWDai
2026-04-17 14:21:43 +08:00
committed by GitHub
parent e5d3722adf
commit b8702ae124
39 changed files with 1739 additions and 128 deletions

View File

@@ -101,6 +101,16 @@ pub(crate) fn apply_local_candidate_terminal_plan_reason(
let skipped_candidate_count = diagnostic.skipped_candidate_count.unwrap_or(0); let skipped_candidate_count = diagnostic.skipped_candidate_count.unwrap_or(0);
diagnostic.reason = if candidate_count == 0 { diagnostic.reason = if candidate_count == 0 {
"candidate_list_empty".to_string() "candidate_list_empty".to_string()
} else if skipped_candidate_count >= candidate_count
&& diagnostic.skip_reasons.len() == 1
&& diagnostic
.skip_reasons
.get("api_key_concurrency_limit_reached")
.copied()
.unwrap_or(0)
> 0
{
"api_key_concurrency_limit_reached".to_string()
} else if skipped_candidate_count >= candidate_count { } else if skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string() "all_candidates_skipped".to_string()
} else { } else {
@@ -222,7 +232,15 @@ mod tests {
apply_local_candidate_terminal_plan_reason(&mut diagnostic, "no_local_sync_plans"); apply_local_candidate_terminal_plan_reason(&mut diagnostic, "no_local_sync_plans");
assert_eq!(diagnostic.reason, "all_candidates_skipped"); assert_eq!(diagnostic.reason, "all_candidates_skipped");
diagnostic.skip_reasons = std::collections::BTreeMap::from([(
"api_key_concurrency_limit_reached".to_string(),
2,
)]);
apply_local_candidate_terminal_plan_reason(&mut diagnostic, "no_local_sync_plans");
assert_eq!(diagnostic.reason, "api_key_concurrency_limit_reached");
diagnostic.skipped_candidate_count = Some(1); diagnostic.skipped_candidate_count = Some(1);
diagnostic.skip_reasons.clear();
apply_local_candidate_terminal_plan_reason(&mut diagnostic, "no_local_sync_plans"); apply_local_candidate_terminal_plan_reason(&mut diagnostic, "no_local_sync_plans");
assert_eq!(diagnostic.reason, "no_local_sync_plans"); assert_eq!(diagnostic.reason, "no_local_sync_plans");
} }

View File

@@ -47,7 +47,7 @@ pub(crate) fn build_local_candidate_persistence_policy<'a>(
LocalCandidatePersistencePolicyKind::OpenAiCliDecision => ( LocalCandidatePersistencePolicyKind::OpenAiCliDecision => (
"gateway local openai cli decision request candidate upsert failed", "gateway local openai cli decision request candidate upsert failed",
"gateway local openai cli decision failed to persist skipped candidate", "gateway local openai cli decision failed to persist skipped candidate",
false, true,
), ),
LocalCandidatePersistencePolicyKind::GeminiFilesDecision => ( LocalCandidatePersistencePolicyKind::GeminiFilesDecision => (
"gateway local gemini files request candidate upsert failed", "gateway local gemini files request candidate upsert failed",

View File

@@ -1,6 +1,12 @@
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate; use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use std::time::Duration;
use tokio::time::Instant;
use super::{GatewayAuthApiKeySnapshot, PlannerAppState}; use super::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_secs;
use crate::constants::{
API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS, API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS,
};
use crate::scheduler::candidate::SchedulerSkippedCandidate; use crate::scheduler::candidate::SchedulerSkippedCandidate;
use crate::GatewayError; use crate::GatewayError;
@@ -42,17 +48,39 @@ impl<'a> PlannerAppState<'a> {
), ),
GatewayError, GatewayError,
> { > {
crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons( let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
self.app().data.as_ref(), let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
self.app(), let wait_deadline = Instant::now() + wait_timeout;
api_format, let mut attempt_now_unix_secs = now_unix_secs;
global_model_name,
require_streaming, loop {
required_capabilities, let result = crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons(
auth_snapshot, self.app().data.as_ref(),
now_unix_secs, self.app(),
) api_format,
.await global_model_name,
require_streaming,
required_capabilities,
auth_snapshot,
attempt_now_unix_secs,
)
.await?;
if !crate::scheduler::candidate::is_exact_all_skipped_by_auth_limit(
&result.0, &result.1,
) {
return Ok(result);
}
let now = Instant::now();
if now >= wait_deadline {
return Ok(result);
}
let remaining = wait_deadline.duration_since(now);
tokio::time::sleep(wait_interval.min(remaining)).await;
attempt_now_unix_secs = current_unix_secs();
}
} }
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model( pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model(
@@ -63,15 +91,35 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64, now_unix_secs: u64,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
crate::scheduler::candidate::list_selectable_candidates_for_required_capability_without_requested_model( let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
self.app().data.as_ref(), let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
self.app(), let wait_deadline = Instant::now() + wait_timeout;
candidate_api_format, let mut attempt_now_unix_secs = now_unix_secs;
required_capability,
require_streaming, loop {
auth_snapshot, let (result, auth_limit_blocked) = crate::scheduler::candidate::list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal(
now_unix_secs, self.app().data.as_ref(),
) self.app(),
.await candidate_api_format,
required_capability,
require_streaming,
auth_snapshot,
attempt_now_unix_secs,
)
.await?;
if !auth_limit_blocked {
return Ok(result);
}
let now = Instant::now();
if now >= wait_deadline {
return Ok(result);
}
let remaining = wait_deadline.duration_since(now);
tokio::time::sleep(wait_interval.min(remaining)).await;
attempt_now_unix_secs = current_unix_secs();
}
} }
} }

View File

@@ -26,6 +26,10 @@ pub(crate) const EXECUTION_PATH_EXECUTION_RUNTIME_STREAM: &str = "execution_runt
pub(crate) const EXECUTION_PATH_CONTROL_EXECUTE_SYNC: &str = "control_execute_sync"; pub(crate) const EXECUTION_PATH_CONTROL_EXECUTE_SYNC: &str = "control_execute_sync";
pub(crate) const EXECUTION_PATH_CONTROL_EXECUTE_STREAM: &str = "control_execute_stream"; pub(crate) const EXECUTION_PATH_CONTROL_EXECUTE_STREAM: &str = "control_execute_stream";
pub(crate) const EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS: &str = "local_execution_runtime_miss"; pub(crate) const EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS: &str = "local_execution_runtime_miss";
pub(crate) const EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED: &str =
"local_api_key_concurrency_limited";
pub(crate) const API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS: u64 = 150;
pub(crate) const API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS: u64 = 10;
pub(crate) const EXECUTION_PATH_LOCAL_AUTH_DENIED: &str = "local_auth_denied"; pub(crate) const EXECUTION_PATH_LOCAL_AUTH_DENIED: &str = "local_auth_denied";
pub(crate) const EXECUTION_PATH_LOCAL_RATE_LIMITED: &str = "local_rate_limited"; pub(crate) const EXECUTION_PATH_LOCAL_RATE_LIMITED: &str = "local_rate_limited";
pub(crate) const EXECUTION_PATH_LOCAL_ROUTE_NOT_FOUND: &str = "local_route_not_found"; pub(crate) const EXECUTION_PATH_LOCAL_ROUTE_NOT_FOUND: &str = "local_route_not_found";

View File

@@ -1,5 +1,5 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::time::Instant; use std::time::{Duration, Instant};
use aether_contracts::ExecutionPlan; use aether_contracts::ExecutionPlan;
use aether_data_contracts::repository::candidates::{ use aether_data_contracts::repository::candidates::{
@@ -73,6 +73,23 @@ impl LocalExecutionRuntimeMissContext {
self.candidate_contexts.len() self.candidate_contexts.len()
} }
pub(crate) fn all_candidates_skipped_for_reason(&self, reason: &str) -> bool {
let reason = reason.trim();
if reason.is_empty() || self.candidate_contexts.is_empty() {
return false;
}
self.candidate_contexts.iter().all(|candidate| {
candidate.candidate.status == RequestCandidateStatus::Skipped
&& candidate
.candidate
.skip_reason
.as_deref()
.map(str::trim)
.is_some_and(|value| value == reason)
})
}
pub(crate) fn candidate_summary(&self) -> Option<String> { pub(crate) fn candidate_summary(&self) -> Option<String> {
const MAX_ITEMS: usize = 5; const MAX_ITEMS: usize = 5;
@@ -166,7 +183,10 @@ pub(crate) async fn build_local_execution_runtime_miss_context(
auth_api_key_id: auth_context.map(|value| value.api_key_id.clone()), auth_api_key_id: auth_context.map(|value| value.api_key_id.clone()),
auth_username: auth_context.and_then(|value| value.username.clone()), auth_username: auth_context.and_then(|value| value.username.clone()),
auth_api_key_name: auth_context.and_then(|value| value.api_key_name.clone()), auth_api_key_name: auth_context.and_then(|value| value.api_key_name.clone()),
candidate_contexts: load_runtime_miss_candidate_contexts(state, request_id, decision).await, candidate_contexts: load_runtime_miss_candidate_contexts_with_retry(
state, request_id, decision,
)
.await,
} }
} }
@@ -175,6 +195,7 @@ pub(crate) async fn record_failed_usage_for_exhausted_request(
exhaustion: LocalExecutionExhaustion, exhaustion: LocalExecutionExhaustion,
started_at: &Instant, started_at: &Instant,
local_execution_runtime_miss_detail: &str, local_execution_runtime_miss_detail: &str,
execution_path: &str,
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>, diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
) { ) {
if !state.usage_runtime.is_enabled() { if !state.usage_runtime.is_enabled() {
@@ -244,6 +265,7 @@ pub(crate) async fn record_failed_usage_for_exhausted_request(
apply_runtime_miss_usage_routing( apply_runtime_miss_usage_routing(
&mut data, &mut data,
&mut request_metadata, &mut request_metadata,
execution_path,
candidate_id.as_deref(), candidate_id.as_deref(),
candidate_index, candidate_index,
None, None,
@@ -264,6 +286,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
request_id: &str, request_id: &str,
started_at: &Instant, started_at: &Instant,
local_execution_runtime_miss_detail: &str, local_execution_runtime_miss_detail: &str,
execution_path: &str,
decision: Option<&GatewayControlDecision>, decision: Option<&GatewayControlDecision>,
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>, diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
context: &LocalExecutionRuntimeMissContext, context: &LocalExecutionRuntimeMissContext,
@@ -272,7 +295,8 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
return; return;
} }
let selected_candidate = select_last_runtime_miss_candidate(&context.candidate_contexts); let selected_candidate =
select_last_runtime_miss_executed_candidate(&context.candidate_contexts);
let api_format = selected_candidate let api_format = selected_candidate
.and_then(|value| value.client_api_format.clone()) .and_then(|value| value.client_api_format.clone())
.or_else(|| { .or_else(|| {
@@ -371,6 +395,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
apply_runtime_miss_usage_routing( apply_runtime_miss_usage_routing(
&mut data, &mut data,
&mut request_metadata, &mut request_metadata,
execution_path,
selected_candidate.map(|value| value.candidate.id.as_str()), selected_candidate.map(|value| value.candidate.id.as_str()),
selected_candidate.map(|value| value.candidate.candidate_index), selected_candidate.map(|value| value.candidate.candidate_index),
selected_candidate.and_then(|value| value.key_name.as_deref()), selected_candidate.and_then(|value| value.key_name.as_deref()),
@@ -410,20 +435,34 @@ fn select_last_failed_request_candidate(
}) })
} }
fn select_last_runtime_miss_candidate( fn select_last_runtime_miss_executed_candidate(
candidates: &[RuntimeMissCandidateContext], candidates: &[RuntimeMissCandidateContext],
) -> Option<&RuntimeMissCandidateContext> { ) -> Option<&RuntimeMissCandidateContext> {
candidates.iter().max_by_key(|candidate| { candidates
( .iter()
candidate.candidate.retry_index, .filter(|candidate| request_candidate_represents_provider_execution(&candidate.candidate))
candidate.candidate.candidate_index, .max_by_key(|candidate| {
candidate (
.candidate candidate.candidate.retry_index,
.finished_at_unix_ms candidate.candidate.candidate_index,
.or(candidate.candidate.started_at_unix_ms) candidate
.unwrap_or(candidate.candidate.created_at_unix_ms), .candidate
) .finished_at_unix_ms
}) .or(candidate.candidate.started_at_unix_ms)
.unwrap_or(candidate.candidate.created_at_unix_ms),
)
})
}
fn request_candidate_represents_provider_execution(candidate: &StoredRequestCandidate) -> bool {
matches!(
candidate.status,
RequestCandidateStatus::Pending
| RequestCandidateStatus::Streaming
| RequestCandidateStatus::Success
| RequestCandidateStatus::Failed
| RequestCandidateStatus::Cancelled
)
} }
fn error_category_for_failed_status(status_code: u16) -> Option<String> { fn error_category_for_failed_status(status_code: u16) -> Option<String> {
@@ -582,6 +621,27 @@ async fn load_runtime_miss_candidate_contexts(
.collect() .collect()
} }
async fn load_runtime_miss_candidate_contexts_with_retry(
state: &AppState,
request_id: &str,
decision: Option<&GatewayControlDecision>,
) -> Vec<RuntimeMissCandidateContext> {
let mut contexts = load_runtime_miss_candidate_contexts(state, request_id, decision).await;
if !contexts.is_empty() {
return contexts;
}
for _ in 0..4 {
tokio::time::sleep(Duration::from_millis(10)).await;
contexts = load_runtime_miss_candidate_contexts(state, request_id, decision).await;
if !contexts.is_empty() {
break;
}
}
contexts
}
fn collect_present_ids<'a>(ids: impl Iterator<Item = &'a str>) -> Vec<String> { fn collect_present_ids<'a>(ids: impl Iterator<Item = &'a str>) -> Vec<String> {
ids.filter_map(|value| { ids.filter_map(|value| {
let trimmed = value.trim(); let trimmed = value.trim();
@@ -740,6 +800,7 @@ fn infer_endpoint_kind(api_format: &str) -> Option<&str> {
fn apply_runtime_miss_usage_routing( fn apply_runtime_miss_usage_routing(
data: &mut UsageEventData, data: &mut UsageEventData,
request_metadata: &mut Map<String, Value>, request_metadata: &mut Map<String, Value>,
execution_path: &str,
candidate_id: Option<&str>, candidate_id: Option<&str>,
candidate_index: Option<u32>, candidate_index: Option<u32>,
key_name: Option<&str>, key_name: Option<&str>,
@@ -761,7 +822,7 @@ fn apply_runtime_miss_usage_routing(
data.execution_path = data data.execution_path = data
.execution_path .execution_path
.clone() .clone()
.or_else(|| Some(EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS.to_string())); .or_else(|| trimmed_non_empty(Some(execution_path)));
data.local_execution_runtime_miss_reason = data data.local_execution_runtime_miss_reason = data
.local_execution_runtime_miss_reason .local_execution_runtime_miss_reason
.clone() .clone()
@@ -796,9 +857,15 @@ fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::apply_runtime_miss_usage_routing; use super::{
apply_runtime_miss_usage_routing, request_candidate_represents_provider_execution,
select_last_runtime_miss_executed_candidate, RuntimeMissCandidateContext,
};
use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS; use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS;
use crate::state::LocalExecutionRuntimeMissDiagnostic; use crate::state::LocalExecutionRuntimeMissDiagnostic;
use aether_data_contracts::repository::candidates::{
RequestCandidateStatus, StoredRequestCandidate,
};
use aether_usage_runtime::UsageEventData; use aether_usage_runtime::UsageEventData;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
@@ -811,6 +878,7 @@ mod tests {
apply_runtime_miss_usage_routing( apply_runtime_miss_usage_routing(
&mut data, &mut data,
&mut request_metadata, &mut request_metadata,
EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS,
Some("cand-1"), Some("cand-1"),
Some(2), Some(2),
Some("primary"), Some("primary"),
@@ -846,4 +914,52 @@ mod tests {
}) })
); );
} }
#[test]
fn runtime_miss_executed_candidate_selection_ignores_skipped_only_histories() {
let skipped_candidate = StoredRequestCandidate::new(
"cand-skipped".to_string(),
"req-1".to_string(),
Some("user-1".to_string()),
Some("api-key-1".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
0,
0,
Some("provider-1".to_string()),
Some("endpoint-1".to_string()),
Some("provider-key-1".to_string()),
RequestCandidateStatus::Skipped,
Some("api_key_concurrency_limit_reached".to_string()),
false,
None,
None,
None,
None,
None,
None,
None,
100,
None,
None,
)
.expect("candidate should build");
assert!(!request_candidate_represents_provider_execution(
&skipped_candidate
));
let contexts = vec![RuntimeMissCandidateContext {
candidate: skipped_candidate,
provider_name: Some("openai".to_string()),
key_name: Some("prod".to_string()),
client_api_format: Some("openai:cli".to_string()),
provider_api_format: Some("openai:cli".to_string()),
global_model_name: Some("gpt-5".to_string()),
selected_provider_model_name: Some("gpt-5-upstream".to_string()),
endpoint_url: Some("https://api.openai.example/v1/responses".to_string()),
}];
assert!(select_last_runtime_miss_executed_candidate(&contexts).is_none());
}
} }

View File

@@ -13,6 +13,7 @@ use crate::handlers::admin::users::{
normalize_admin_optional_api_key_name, normalize_admin_user_api_formats, normalize_admin_optional_api_key_name, normalize_admin_user_api_formats,
normalize_admin_user_string_list, normalize_admin_user_string_list,
}; };
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
use crate::GatewayError; use crate::GatewayError;
use aether_admin::system::serialize_admin_system_users_export_wallet; use aether_admin::system::serialize_admin_system_users_export_wallet;
use axum::{ use axum::{
@@ -114,6 +115,11 @@ pub(super) async fn build_admin_create_api_key_response(
"rate_limit 必须大于等于 0", "rate_limit 必须大于等于 0",
)); ));
} }
let concurrent_limit =
match normalize_optional_api_key_concurrent_limit(payload.concurrent_limit) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let (initial_balance_usd, unlimited_balance) = match normalize_standalone_initial_balance( let (initial_balance_usd, unlimited_balance) = match normalize_standalone_initial_balance(
payload.initial_balance_usd, payload.initial_balance_usd,
payload.unlimited_balance, payload.unlimited_balance,
@@ -154,7 +160,7 @@ pub(super) async fn build_admin_create_api_key_response(
allowed_api_formats, allowed_api_formats,
allowed_models, allowed_models,
rate_limit: payload.rate_limit, rate_limit: payload.rate_limit,
concurrent_limit: 5, concurrent_limit,
force_capabilities: None, force_capabilities: None,
is_active: true, is_active: true,
expires_at_unix_secs, expires_at_unix_secs,
@@ -184,6 +190,7 @@ pub(super) async fn build_admin_create_api_key_response(
"is_standalone": true, "is_standalone": true,
"is_active": created.is_active, "is_active": created.is_active,
"rate_limit": created.rate_limit, "rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"allowed_providers": created.allowed_providers, "allowed_providers": created.allowed_providers,
"allowed_api_formats": created.allowed_api_formats, "allowed_api_formats": created.allowed_api_formats,
"allowed_models": created.allowed_models, "allowed_models": created.allowed_models,
@@ -270,6 +277,11 @@ pub(super) async fn build_admin_update_api_key_response(
"rate_limit 必须大于等于 0", "rate_limit 必须大于等于 0",
)); ));
} }
let concurrent_limit =
match normalize_optional_api_key_concurrent_limit(payload.concurrent_limit) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let allowed_providers = if field_presence.contains("allowed_providers") { let allowed_providers = if field_presence.contains("allowed_providers") {
match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") { match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") {
Ok(value) => Some(value), Ok(value) => Some(value),
@@ -356,6 +368,8 @@ pub(super) async fn build_admin_update_api_key_response(
name, name,
rate_limit_present: field_presence.contains("rate_limit"), rate_limit_present: field_presence.contains("rate_limit"),
rate_limit: payload.rate_limit, rate_limit: payload.rate_limit,
concurrent_limit_present: field_presence.contains("concurrent_limit"),
concurrent_limit,
allowed_providers, allowed_providers,
allowed_api_formats, allowed_api_formats,
allowed_models, allowed_models,

View File

@@ -22,6 +22,7 @@ pub(super) struct AdminStandaloneApiKeyCreateRequest {
pub(super) allowed_api_formats: Option<Vec<String>>, pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>, pub(super) allowed_models: Option<Vec<String>>,
pub(super) rate_limit: Option<i32>, pub(super) rate_limit: Option<i32>,
pub(super) concurrent_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>, pub(super) initial_balance_usd: Option<f64>,
pub(super) unlimited_balance: Option<bool>, pub(super) unlimited_balance: Option<bool>,
pub(super) expire_days: Option<i32>, pub(super) expire_days: Option<i32>,
@@ -36,6 +37,7 @@ pub(super) struct AdminStandaloneApiKeyUpdateRequest {
pub(super) allowed_api_formats: Option<Vec<String>>, pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>, pub(super) allowed_models: Option<Vec<String>>,
pub(super) rate_limit: Option<i32>, pub(super) rate_limit: Option<i32>,
pub(super) concurrent_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>, pub(super) initial_balance_usd: Option<f64>,
pub(super) unlimited_balance: Option<bool>, pub(super) unlimited_balance: Option<bool>,
pub(super) expire_days: Option<i32>, pub(super) expire_days: Option<i32>,
@@ -144,6 +146,7 @@ pub(super) fn build_admin_api_key_list_item_payload(
"total_tokens": total_tokens, "total_tokens": total_tokens,
"total_cost_usd": record.total_cost_usd, "total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit, "rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"allowed_providers": record.allowed_providers, "allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats, "allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models, "allowed_models": record.allowed_models,
@@ -173,6 +176,7 @@ pub(super) fn build_admin_api_key_detail_payload(
"total_tokens": total_tokens, "total_tokens": total_tokens,
"total_cost_usd": record.total_cost_usd, "total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit, "rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"allowed_providers": record.allowed_providers, "allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats, "allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models, "allowed_models": record.allowed_models,

View File

@@ -1871,8 +1871,10 @@ impl<'a> AdminAppState<'a> {
let concurrent_limit = invalid_value!(imported_optional_i32( let concurrent_limit = invalid_value!(imported_optional_i32(
key.get("concurrent_limit"), key.get("concurrent_limit"),
"concurrent_limit" "concurrent_limit"
)) ));
.unwrap_or(5); if concurrent_limit.is_some_and(|value| value < 0) {
return Ok(Err(invalid_request("concurrent_limit 必须是非负整数")));
}
let force_capabilities = imported_optional_value(key.get("force_capabilities")); let force_capabilities = imported_optional_value(key.get("force_capabilities"));
let is_active = let is_active =
invalid_value!(imported_optional_bool(key.get("is_active"))).unwrap_or(true); invalid_value!(imported_optional_bool(key.get("is_active"))).unwrap_or(true);
@@ -1913,6 +1915,11 @@ impl<'a> AdminAppState<'a> {
api_key_id: existing_key.api_key_id.clone(), api_key_id: existing_key.api_key_id.clone(),
name: name.clone(), name: name.clone(),
rate_limit: Some(rate_limit), rate_limit: Some(rate_limit),
concurrent_limit: if key.contains_key("concurrent_limit") {
concurrent_limit
} else {
None
},
}, },
) )
.await?; .await?;
@@ -2051,8 +2058,10 @@ impl<'a> AdminAppState<'a> {
let concurrent_limit = invalid_value!(imported_optional_i32( let concurrent_limit = invalid_value!(imported_optional_i32(
key.get("concurrent_limit"), key.get("concurrent_limit"),
"concurrent_limit" "concurrent_limit"
)) ));
.unwrap_or(5); if concurrent_limit.is_some_and(|value| value < 0) {
return Ok(Err(invalid_request("concurrent_limit 必须是非负整数")));
}
let force_capabilities = imported_optional_value(key.get("force_capabilities")); let force_capabilities = imported_optional_value(key.get("force_capabilities"));
let is_active = let is_active =
invalid_value!(imported_optional_bool(key.get("is_active"))).unwrap_or(true); invalid_value!(imported_optional_bool(key.get("is_active"))).unwrap_or(true);
@@ -2099,6 +2108,8 @@ impl<'a> AdminAppState<'a> {
name: name.clone(), name: name.clone(),
rate_limit_present: true, rate_limit_present: true,
rate_limit: Some(rate_limit), rate_limit: Some(rate_limit),
concurrent_limit_present: key.contains_key("concurrent_limit"),
concurrent_limit,
allowed_providers: Some(allowed_providers.clone()), allowed_providers: Some(allowed_providers.clone()),
allowed_api_formats: Some(allowed_api_formats.clone()), allowed_api_formats: Some(allowed_api_formats.clone()),
allowed_models: Some(allowed_models.clone()), allowed_models: Some(allowed_models.clone()),
@@ -2123,7 +2134,6 @@ impl<'a> AdminAppState<'a> {
|| key.contains_key("force_capabilities") || key.contains_key("force_capabilities")
|| key.contains_key("total_requests") || key.contains_key("total_requests")
|| key.contains_key("total_cost_usd") || key.contains_key("total_cost_usd")
|| key.contains_key("concurrent_limit")
{ {
stats.errors.push( stats.errors.push(
"现有独立余额 Key 仅覆盖基础字段;高级导入字段保持原值".to_string(), "现有独立余额 Key 仅覆盖基础字段;高级导入字段保持原值".to_string(),

View File

@@ -43,6 +43,7 @@ pub(super) fn build_admin_user_api_key_detail_payload(
"total_requests": record.total_requests, "total_requests": record.total_requests,
"total_cost_usd": record.total_cost_usd, "total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit, "rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs), "expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"last_used_at": serde_json::Value::Null, "last_used_at": serde_json::Value::Null,
"created_at": serde_json::Value::Null, "created_at": serde_json::Value::Null,

View File

@@ -10,6 +10,7 @@ use super::super::helpers::{
use super::super::paths::admin_user_id_from_api_keys_path; use super::super::paths::admin_user_id_from_api_keys_path;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
use crate::GatewayError; use crate::GatewayError;
use axum::{ use axum::{
body::Body, body::Body,
@@ -69,7 +70,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
{ {
return Ok(( return Ok((
http::StatusCode::BAD_REQUEST, http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "当前仅支持 name、rate_limit、allowed_providers 字段" })), Json(json!({ "detail": "当前仅支持 name、rate_limit、concurrent_limit、allowed_providers 字段" })),
) )
.into_response()); .into_response());
} }
@@ -103,6 +104,17 @@ pub(crate) async fn build_admin_create_user_api_key_response(
) )
.into_response()); .into_response());
} }
let concurrent_limit =
match normalize_optional_api_key_concurrent_limit(payload.concurrent_limit) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response());
}
};
let plaintext_key = generate_admin_user_api_key_plaintext(); let plaintext_key = generate_admin_user_api_key_plaintext();
let Some(key_encrypted) = state.encrypt_catalog_secret_with_fallbacks(&plaintext_key) else { let Some(key_encrypted) = state.encrypt_catalog_secret_with_fallbacks(&plaintext_key) else {
@@ -124,7 +136,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
allowed_api_formats: None, allowed_api_formats: None,
allowed_models: None, allowed_models: None,
rate_limit, rate_limit,
concurrent_limit: 5, concurrent_limit,
force_capabilities: None, force_capabilities: None,
is_active: true, is_active: true,
expires_at_unix_secs: None, expires_at_unix_secs: None,
@@ -156,6 +168,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
"name": created.name, "name": created.name,
"key_display": masked_user_api_key_display(state, created.key_encrypted.as_deref()), "key_display": masked_user_api_key_display(state, created.key_encrypted.as_deref()),
"rate_limit": created.rate_limit, "rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs), "expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
"created_at": chrono::Utc::now().to_rfc3339(), "created_at": chrono::Utc::now().to_rfc3339(),
"message": "API Key创建成功请妥善保存完整密钥", "message": "API Key创建成功请妥善保存完整密钥",

View File

@@ -62,6 +62,7 @@ pub(crate) async fn build_admin_list_user_api_keys_response(
"total_requests": record.total_requests, "total_requests": record.total_requests,
"total_cost_usd": record.total_cost_usd, "total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit, "rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs), "expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"last_used_at": serde_json::Value::Null, "last_used_at": serde_json::Value::Null,
"created_at": serde_json::Value::Null, "created_at": serde_json::Value::Null,

View File

@@ -9,6 +9,7 @@ use super::super::helpers::{
use super::super::paths::admin_user_api_key_parts; use super::super::paths::admin_user_api_key_parts;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
use crate::GatewayError; use crate::GatewayError;
use axum::{ use axum::{
body::Body, body::Body,
@@ -68,6 +69,17 @@ pub(crate) async fn build_admin_update_user_api_key_response(
) )
.into_response()); .into_response());
} }
let concurrent_limit =
match normalize_optional_api_key_concurrent_limit(payload.concurrent_limit) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response());
}
};
let Some(updated) = state let Some(updated) = state
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord { .update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
@@ -75,6 +87,7 @@ pub(crate) async fn build_admin_update_user_api_key_response(
api_key_id: api_key_id.clone(), api_key_id: api_key_id.clone(),
name, name,
rate_limit: payload.rate_limit, rate_limit: payload.rate_limit,
concurrent_limit,
}) })
.await? .await?
else { else {

View File

@@ -22,6 +22,8 @@ pub(super) struct AdminCreateUserApiKeyRequest {
#[serde(default)] #[serde(default)]
pub(super) rate_limit: Option<i32>, pub(super) rate_limit: Option<i32>,
#[serde(default)] #[serde(default)]
pub(super) concurrent_limit: Option<i32>,
#[serde(default)]
pub(super) expire_days: Option<i32>, pub(super) expire_days: Option<i32>,
#[serde(default)] #[serde(default)]
pub(super) expires_at: Option<String>, pub(super) expires_at: Option<String>,
@@ -41,6 +43,8 @@ pub(super) struct AdminUpdateUserApiKeyRequest {
pub(super) name: Option<String>, pub(super) name: Option<String>,
#[serde(default)] #[serde(default)]
pub(super) rate_limit: Option<i32>, pub(super) rate_limit: Option<i32>,
#[serde(default)]
pub(super) concurrent_limit: Option<i32>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]

View File

@@ -14,14 +14,14 @@ use crate::constants::{
DEPENDENCY_REASON_HEADER, EXECUTION_PATH_CONTROL_EXECUTE_STREAM, DEPENDENCY_REASON_HEADER, EXECUTION_PATH_CONTROL_EXECUTE_STREAM,
EXECUTION_PATH_CONTROL_EXECUTE_SYNC, EXECUTION_PATH_DISTRIBUTED_OVERLOADED, EXECUTION_PATH_CONTROL_EXECUTE_SYNC, EXECUTION_PATH_DISTRIBUTED_OVERLOADED,
EXECUTION_PATH_EXECUTION_RUNTIME_STREAM, EXECUTION_PATH_EXECUTION_RUNTIME_SYNC, EXECUTION_PATH_EXECUTION_RUNTIME_STREAM, EXECUTION_PATH_EXECUTION_RUNTIME_SYNC,
EXECUTION_PATH_LOCAL_AI_PUBLIC, EXECUTION_PATH_LOCAL_AUTH_DENIED, EXECUTION_PATH_LOCAL_AI_PUBLIC, EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED,
EXECUTION_PATH_LOCAL_EXECUTION_LOOP_DETECTED, EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS, EXECUTION_PATH_LOCAL_AUTH_DENIED, EXECUTION_PATH_LOCAL_EXECUTION_LOOP_DETECTED,
EXECUTION_PATH_LOCAL_OVERLOADED, EXECUTION_PATH_LOCAL_PROXY_PASSTHROUGH_REMOVED, EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS, EXECUTION_PATH_LOCAL_OVERLOADED,
EXECUTION_PATH_LOCAL_RATE_LIMITED, EXECUTION_PATH_LOCAL_ROUTE_NOT_FOUND, EXECUTION_PATH_LOCAL_PROXY_PASSTHROUGH_REMOVED, EXECUTION_PATH_LOCAL_RATE_LIMITED,
EXECUTION_PATH_PUBLIC_PROXY_PASSTHROUGH, EXECUTION_RUNTIME_LOOP_GUARD_HEADER, EXECUTION_PATH_LOCAL_ROUTE_NOT_FOUND, EXECUTION_PATH_PUBLIC_PROXY_PASSTHROUGH,
FORWARDED_FOR_HEADER, FORWARDED_HOST_HEADER, FORWARDED_PROTO_HEADER, GATEWAY_HEADER, EXECUTION_RUNTIME_LOOP_GUARD_HEADER, FORWARDED_FOR_HEADER, FORWARDED_HOST_HEADER,
LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER, TRACE_ID_HEADER, FORWARDED_PROTO_HEADER, GATEWAY_HEADER, LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER,
TRUSTED_AUTH_ACCESS_ALLOWED_HEADER, TRUSTED_AUTH_API_KEY_ID_HEADER, TRACE_ID_HEADER, TRUSTED_AUTH_ACCESS_ALLOWED_HEADER, TRUSTED_AUTH_API_KEY_ID_HEADER,
TRUSTED_AUTH_BALANCE_HEADER, TRUSTED_AUTH_USER_ID_HEADER, TUNNEL_AFFINITY_FORWARDED_BY_HEADER, TRUSTED_AUTH_BALANCE_HEADER, TRUSTED_AUTH_USER_ID_HEADER, TUNNEL_AFFINITY_FORWARDED_BY_HEADER,
TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER, TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER,
}; };
@@ -75,6 +75,8 @@ const LOCAL_PROXY_PASSTHROUGH_REMOVED_DETAIL: &str =
"Route matched a removed compatibility passthrough; implement it in Rust or retire the route"; "Route matched a removed compatibility passthrough; implement it in Rust or retire the route";
const LOCAL_EXECUTION_LOOP_DETECTED_DETAIL: &str = const LOCAL_EXECUTION_LOOP_DETECTED_DETAIL: &str =
"Gateway detected an execution runtime request loop back into the local frontdoor"; "Gateway detected an execution runtime request loop back into the local frontdoor";
const AUTH_API_KEY_CONCURRENCY_LIMIT_REACHED_DETAIL: &str =
"当前 API Key 并发请求数已达上限,请稍后重试";
const EXECUTION_PATH_TUNNEL_AFFINITY_FORWARD: &str = "tunnel_affinity_forward"; const EXECUTION_PATH_TUNNEL_AFFINITY_FORWARD: &str = "tunnel_affinity_forward";
fn local_execution_outcome_label(outcome: &LocalExecutionRequestOutcome) -> &'static str { fn local_execution_outcome_label(outcome: &LocalExecutionRequestOutcome) -> &'static str {
@@ -1018,23 +1020,38 @@ pub(crate) async fn proxy_request(
} }
let local_execution_runtime_miss_diagnostic = let local_execution_runtime_miss_diagnostic =
state.take_local_execution_runtime_miss_diagnostic(&trace_id); state.take_local_execution_runtime_miss_diagnostic(&trace_id);
let local_execution_runtime_miss_context =
build_local_execution_runtime_miss_context(&state, &trace_id, control_decision).await;
let auth_api_key_concurrency_limited = diagnostic_is_auth_api_key_concurrency_limited(
local_execution_runtime_miss_diagnostic.as_ref(),
) || local_execution_runtime_miss_context
.all_candidates_skipped_for_reason("api_key_concurrency_limit_reached");
let local_execution_runtime_miss_detail = local_execution_runtime_miss_detail( let local_execution_runtime_miss_detail = local_execution_runtime_miss_detail(
control_decision, control_decision,
local_execution_runtime_miss_diagnostic.as_ref(), local_execution_runtime_miss_diagnostic.as_ref(),
auth_api_key_concurrency_limited,
stream_request, stream_request,
) )
.unwrap_or_else(|| { .unwrap_or_else(|| {
"AI public execution runtime miss did not match a Rust execution path".to_string() "AI public execution runtime miss did not match a Rust execution path".to_string()
}); });
let local_execution_failure_path = if auth_api_key_concurrency_limited {
EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED
} else {
EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS
};
let local_execution_failure_log = if auth_api_key_concurrency_limited {
"gateway local execution blocked by api key concurrency limit"
} else {
"gateway local execution runtime miss"
};
state.record_fallback_metric( state.record_fallback_metric(
GatewayFallbackMetricKind::LocalExecutionRuntimeMiss, GatewayFallbackMetricKind::LocalExecutionRuntimeMiss,
control_decision, control_decision,
None, None,
Some(EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS), Some(local_execution_failure_path),
GatewayFallbackReason::LocalExecutionPathRequired, GatewayFallbackReason::LocalExecutionPathRequired,
); );
let local_execution_runtime_miss_context =
build_local_execution_runtime_miss_context(&state, &trace_id, control_decision).await;
warn!( warn!(
trace_id = %trace_id, trace_id = %trace_id,
local_execution_runtime_miss_reason = local_execution_runtime_miss_diagnostic local_execution_runtime_miss_reason = local_execution_runtime_miss_diagnostic
@@ -1094,7 +1111,7 @@ pub(crate) async fn proxy_request(
request_candidates = local_execution_runtime_miss_context request_candidates = local_execution_runtime_miss_context
.candidate_summary() .candidate_summary()
.unwrap_or_default(), .unwrap_or_default(),
"gateway local execution runtime miss" local_execution_failure_log
); );
if let Some(exhaustion) = local_execution_exhaustion { if let Some(exhaustion) = local_execution_exhaustion {
record_failed_usage_for_exhausted_request( record_failed_usage_for_exhausted_request(
@@ -1102,6 +1119,7 @@ pub(crate) async fn proxy_request(
exhaustion, exhaustion,
&started_at, &started_at,
local_execution_runtime_miss_detail.as_str(), local_execution_runtime_miss_detail.as_str(),
local_execution_failure_path,
local_execution_runtime_miss_diagnostic.as_ref(), local_execution_runtime_miss_diagnostic.as_ref(),
) )
.await; .await;
@@ -1111,6 +1129,7 @@ pub(crate) async fn proxy_request(
&trace_id, &trace_id,
&started_at, &started_at,
local_execution_runtime_miss_detail.as_str(), local_execution_runtime_miss_detail.as_str(),
local_execution_failure_path,
control_decision, control_decision,
local_execution_runtime_miss_diagnostic.as_ref(), local_execution_runtime_miss_diagnostic.as_ref(),
&local_execution_runtime_miss_context, &local_execution_runtime_miss_context,
@@ -1123,21 +1142,28 @@ pub(crate) async fn proxy_request(
http::StatusCode::SERVICE_UNAVAILABLE, http::StatusCode::SERVICE_UNAVAILABLE,
local_execution_runtime_miss_detail.as_str(), local_execution_runtime_miss_detail.as_str(),
)?; )?;
if let Some(diagnostic) = local_execution_runtime_miss_diagnostic { let local_execution_runtime_miss_reason = local_execution_runtime_miss_diagnostic
if !diagnostic.reason.trim().is_empty() { .as_ref()
response.headers_mut().insert( .map(|diagnostic| diagnostic.reason.trim())
HeaderName::from_static(LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER), .filter(|reason| !reason.is_empty())
HeaderValue::from_str(diagnostic.reason.as_str()) .map(ToOwned::to_owned)
.map_err(|err| GatewayError::Internal(err.to_string()))?, .or_else(|| {
); auth_api_key_concurrency_limited
} .then_some("api_key_concurrency_limit_reached".to_string())
});
if let Some(reason) = local_execution_runtime_miss_reason {
response.headers_mut().insert(
HeaderName::from_static(LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER),
HeaderValue::from_str(reason.as_str())
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
} }
return Ok(finalize_gateway_response_with_context( return Ok(finalize_gateway_response_with_context(
&state, &state,
response, response,
&remote_addr, &remote_addr,
&request_context, &request_context,
EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS, local_execution_failure_path,
&started_at, &started_at,
request_permit.take(), request_permit.take(),
)); ));
@@ -1163,8 +1189,15 @@ pub(crate) async fn proxy_request(
fn local_execution_runtime_miss_detail( fn local_execution_runtime_miss_detail(
decision: Option<&GatewayControlDecision>, decision: Option<&GatewayControlDecision>,
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>, diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
auth_api_key_concurrency_limited: bool,
stream_request: bool, stream_request: bool,
) -> Option<String> { ) -> Option<String> {
if auth_api_key_concurrency_limited
|| diagnostic_is_auth_api_key_concurrency_limited(diagnostic)
{
return Some(AUTH_API_KEY_CONCURRENCY_LIMIT_REACHED_DETAIL.to_string());
}
if let Some(detail) = local_execution_runtime_miss_model_detail(diagnostic, stream_request) { if let Some(detail) = local_execution_runtime_miss_model_detail(diagnostic, stream_request) {
return Some(detail); return Some(detail);
} }
@@ -1195,6 +1228,23 @@ fn local_execution_runtime_miss_model_detail(
)) ))
} }
fn diagnostic_is_auth_api_key_concurrency_limited(
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
) -> bool {
let Some(diagnostic) = diagnostic else {
return false;
};
diagnostic.reason == "api_key_concurrency_limit_reached"
|| (diagnostic.reason == "all_candidates_skipped"
&& diagnostic.skip_reasons.len() == 1
&& diagnostic
.skip_reasons
.get("api_key_concurrency_limit_reached")
.copied()
.unwrap_or(0)
> 0)
}
fn local_execution_runtime_miss_route_detail( fn local_execution_runtime_miss_route_detail(
decision: Option<&GatewayControlDecision>, decision: Option<&GatewayControlDecision>,
) -> Option<&'static str> { ) -> Option<&'static str> {
@@ -1226,8 +1276,8 @@ fn local_execution_runtime_miss_route_detail(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
local_execution_runtime_miss_detail, GatewayControlDecision, diagnostic_is_auth_api_key_concurrency_limited, local_execution_runtime_miss_detail,
LocalExecutionRuntimeMissDiagnostic, GatewayControlDecision, LocalExecutionRuntimeMissDiagnostic,
}; };
#[test] #[test]
@@ -1245,7 +1295,8 @@ mod tests {
..LocalExecutionRuntimeMissDiagnostic::default() ..LocalExecutionRuntimeMissDiagnostic::default()
}; };
let detail = local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), true); let detail =
local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), false, true);
assert_eq!( assert_eq!(
detail.as_deref(), detail.as_deref(),
@@ -1268,13 +1319,73 @@ mod tests {
..LocalExecutionRuntimeMissDiagnostic::default() ..LocalExecutionRuntimeMissDiagnostic::default()
}; };
let detail = local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), false); let detail =
local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), false, false);
assert_eq!( assert_eq!(
detail.as_deref(), detail.as_deref(),
Some("Claude messages execution runtime miss did not match a Rust execution path") Some("Claude messages execution runtime miss did not match a Rust execution path")
); );
} }
#[test]
fn runtime_miss_detail_returns_api_key_concurrency_message_for_exact_all_skipped_limit_case() {
let decision = GatewayControlDecision::synthetic(
"/v1/responses",
Some("ai_public".to_string()),
Some("openai".to_string()),
Some("cli".to_string()),
Some("openai:cli".to_string()),
);
let diagnostic = LocalExecutionRuntimeMissDiagnostic {
reason: "all_candidates_skipped".to_string(),
skip_reasons: std::collections::BTreeMap::from([(
"api_key_concurrency_limit_reached".to_string(),
1,
)]),
requested_model: Some("gpt-5.4".to_string()),
..LocalExecutionRuntimeMissDiagnostic::default()
};
let detail =
local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), false, false);
assert_eq!(
detail.as_deref(),
Some("当前 API Key 并发请求数已达上限,请稍后重试")
);
assert!(diagnostic_is_auth_api_key_concurrency_limited(Some(
&diagnostic
)));
}
#[test]
fn runtime_miss_detail_prefers_api_key_concurrency_message_when_classified_from_context() {
let decision = GatewayControlDecision::synthetic(
"/v1/responses",
Some("ai_public".to_string()),
Some("openai".to_string()),
Some("cli".to_string()),
Some("openai:cli".to_string()),
);
let diagnostic = LocalExecutionRuntimeMissDiagnostic {
reason: "all_candidates_skipped".to_string(),
skip_reasons: std::collections::BTreeMap::from([(
"format_conversion_disabled".to_string(),
1,
)]),
requested_model: Some("gpt-5.4".to_string()),
..LocalExecutionRuntimeMissDiagnostic::default()
};
let detail =
local_execution_runtime_miss_detail(Some(&decision), Some(&diagnostic), true, false);
assert_eq!(
detail.as_deref(),
Some("当前 API Key 并发请求数已达上限,请稍后重试")
);
}
} }
#[path = "finalize.rs"] #[path = "finalize.rs"]

View File

@@ -10,7 +10,8 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use crate::handlers::shared::{ use crate::handlers::shared::{
api_key_placeholder_display, generate_gateway_api_key_plaintext, masked_gateway_api_key_display, api_key_placeholder_display, generate_gateway_api_key_plaintext,
masked_gateway_api_key_display, normalize_optional_api_key_concurrent_limit,
}; };
use super::{ use super::{
@@ -28,6 +29,8 @@ struct UsersMeCreateApiKeyRequest {
name: String, name: String,
#[serde(default)] #[serde(default)]
rate_limit: Option<i32>, rate_limit: Option<i32>,
#[serde(default)]
concurrent_limit: Option<i32>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -36,6 +39,8 @@ struct UsersMeUpdateApiKeyRequest {
name: Option<String>, name: Option<String>,
#[serde(default)] #[serde(default)]
rate_limit: Option<i32>, rate_limit: Option<i32>,
#[serde(default)]
concurrent_limit: Option<i32>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -148,6 +153,7 @@ fn build_users_me_api_key_list_payload(
"total_requests": record.total_requests, "total_requests": record.total_requests,
"total_cost_usd": record.total_cost_usd, "total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit, "rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"allowed_providers": record.allowed_providers, "allowed_providers": record.allowed_providers,
"force_capabilities": record.force_capabilities, "force_capabilities": record.force_capabilities,
}) })
@@ -167,6 +173,7 @@ fn build_users_me_api_key_detail_payload(
"allowed_providers": record.allowed_providers, "allowed_providers": record.allowed_providers,
"force_capabilities": record.force_capabilities, "force_capabilities": record.force_capabilities,
"rate_limit": record.rate_limit, "rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"last_used_at": serde_json::Value::Null, "last_used_at": serde_json::Value::Null,
"expires_at": format_users_me_optional_unix_secs_iso8601(record.expires_at_unix_secs), "expires_at": format_users_me_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": serde_json::Value::Null, "created_at": serde_json::Value::Null,
@@ -515,6 +522,13 @@ pub(super) async fn handle_users_me_api_key_create(
false, false,
); );
} }
let concurrent_limit =
match normalize_optional_api_key_concurrent_limit(payload.concurrent_limit) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let plaintext_key = generate_users_me_api_key_plaintext(); let plaintext_key = generate_users_me_api_key_plaintext();
let Some(key_encrypted) = encrypt_catalog_secret_with_fallbacks(state, &plaintext_key) else { let Some(key_encrypted) = encrypt_catalog_secret_with_fallbacks(state, &plaintext_key) else {
@@ -534,7 +548,7 @@ pub(super) async fn handle_users_me_api_key_create(
allowed_api_formats: None, allowed_api_formats: None,
allowed_models: None, allowed_models: None,
rate_limit, rate_limit,
concurrent_limit: 5, concurrent_limit,
force_capabilities: None, force_capabilities: None,
is_active: true, is_active: true,
expires_at_unix_secs: None, expires_at_unix_secs: None,
@@ -561,6 +575,7 @@ pub(super) async fn handle_users_me_api_key_create(
"key": plaintext_key, "key": plaintext_key,
"key_display": users_me_masked_api_key_display(state, created.key_encrypted.as_deref()), "key_display": users_me_masked_api_key_display(state, created.key_encrypted.as_deref()),
"rate_limit": created.rate_limit, "rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"message": "API密钥创建成功", "message": "API密钥创建成功",
})) }))
.into_response() .into_response()
@@ -621,6 +636,13 @@ pub(super) async fn handle_users_me_api_key_update(
false, false,
); );
} }
let concurrent_limit =
match normalize_optional_api_key_concurrent_limit(payload.concurrent_limit) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let Some(updated) = (match state let Some(updated) = (match state
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord { .update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
@@ -628,6 +650,7 @@ pub(super) async fn handle_users_me_api_key_update(
api_key_id: snapshot.api_key_id.clone(), api_key_id: snapshot.api_key_id.clone(),
name, name,
rate_limit, rate_limit,
concurrent_limit,
}) })
.await .await
{ {

View File

@@ -60,6 +60,15 @@ pub(crate) fn masked_gateway_api_key_display(full_key: Option<&str>) -> String {
format!("{prefix}...{suffix}") format!("{prefix}...{suffix}")
} }
pub(crate) fn normalize_optional_api_key_concurrent_limit(
value: Option<i32>,
) -> Result<Option<i32>, String> {
if value.is_some_and(|limit| limit < 0) {
return Err("concurrent_limit 必须是非负整数".to_string());
}
Ok(value)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{

View File

@@ -15,7 +15,7 @@ pub(crate) use self::admin_proxy::{
}; };
pub(crate) use self::api_keys::{ pub(crate) use self::api_keys::{
api_key_placeholder_display, configured_api_key_prefix, generate_gateway_api_key_plaintext, api_key_placeholder_display, configured_api_key_prefix, generate_gateway_api_key_plaintext,
masked_gateway_api_key_display, masked_gateway_api_key_display, normalize_optional_api_key_concurrent_limit,
}; };
pub(crate) use self::catalog::{ pub(crate) use self::catalog::{
build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks, build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks,

View File

@@ -212,7 +212,7 @@ mod tests {
} }
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn access_log_emits_completed_events_by_default() { async fn access_log_emits_completed_events_by_default() {
let writer = SharedBuffer::default(); let writer = SharedBuffer::default();
let subscriber = tracing_subscriber::registry().with( let subscriber = tracing_subscriber::registry().with(
@@ -271,7 +271,7 @@ mod tests {
assert_eq!(logs[0]["execution_path"], "local_route"); assert_eq!(logs[0]["execution_path"], "local_route");
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn access_log_propagates_generated_trace_id_to_downstream_handler() { async fn access_log_propagates_generated_trace_id_to_downstream_handler() {
let app = Router::new() let app = Router::new()
.route( .route(
@@ -318,7 +318,7 @@ mod tests {
assert_eq!(seen_trace_id, response_trace_id); assert_eq!(seen_trace_id, response_trace_id);
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn access_log_shortens_long_request_ids() { async fn access_log_shortens_long_request_ids() {
let writer = SharedBuffer::default(); let writer = SharedBuffer::default();
let subscriber = tracing_subscriber::registry().with( let subscriber = tracing_subscriber::registry().with(
@@ -371,7 +371,7 @@ mod tests {
assert_eq!(logs[0]["request_id"], "d07e1e94"); assert_eq!(logs[0]["request_id"], "d07e1e94");
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn access_log_emits_failed_events_by_default_for_server_errors() { async fn access_log_emits_failed_events_by_default_for_server_errors() {
let writer = SharedBuffer::default(); let writer = SharedBuffer::default();
let subscriber = tracing_subscriber::registry().with( let subscriber = tracing_subscriber::registry().with(
@@ -419,7 +419,7 @@ mod tests {
assert_eq!(logs[0]["execution_path"], "execution_runtime_sync"); assert_eq!(logs[0]["execution_path"], "execution_runtime_sync");
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn access_log_treats_client_errors_as_completed_events() { async fn access_log_treats_client_errors_as_completed_events() {
let writer = SharedBuffer::default(); let writer = SharedBuffer::default();
let subscriber = tracing_subscriber::registry().with( let subscriber = tracing_subscriber::registry().with(
@@ -467,7 +467,7 @@ mod tests {
assert_eq!(logs[0]["execution_path"], "local_auth_denied"); assert_eq!(logs[0]["execution_path"], "local_auth_denied");
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn access_log_emits_completed_events_for_streaming_responses() { async fn access_log_emits_completed_events_for_streaming_responses() {
let writer = SharedBuffer::default(); let writer = SharedBuffer::default();
let subscriber = tracing_subscriber::registry().with( let subscriber = tracing_subscriber::registry().with(
@@ -522,7 +522,7 @@ mod tests {
assert_eq!(logs[0]["execution_path"], "execution_runtime_stream"); assert_eq!(logs[0]["execution_path"], "execution_runtime_stream");
} }
#[tokio::test] #[tokio::test(flavor = "current_thread")]
async fn access_log_downgrades_usage_active_polling_to_trace() { async fn access_log_downgrades_usage_active_polling_to_trace() {
let writer = SharedBuffer::default(); let writer = SharedBuffer::default();
let subscriber = tracing_subscriber::registry().with( let subscriber = tracing_subscriber::registry().with(

View File

@@ -69,6 +69,13 @@ pub(crate) async fn list_selectable_candidates(
.await .await
} }
pub(crate) fn is_exact_all_skipped_by_auth_limit(
selected: &[SchedulerMinimalCandidateSelectionCandidate],
skipped: &[SchedulerSkippedCandidate],
) -> bool {
selection::is_exact_all_skipped_by_auth_limit(selected, skipped)
}
pub(crate) async fn list_selectable_candidates_with_skip_reasons( pub(crate) async fn list_selectable_candidates_with_skip_reasons(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &impl SchedulerRuntimeState, runtime_state: &impl SchedulerRuntimeState,
@@ -107,9 +114,33 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64, now_unix_secs: u64,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
Ok(
list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal(
selection_row_source,
runtime_state,
candidate_api_format,
required_capability,
require_streaming,
auth_snapshot,
now_unix_secs,
)
.await?
.0,
)
}
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &impl SchedulerRuntimeState,
candidate_api_format: &str,
required_capability: &str,
require_streaming: bool,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
) -> Result<(Vec<SchedulerMinimalCandidateSelectionCandidate>, bool), GatewayError> {
let normalized_api_format = normalize_api_format(candidate_api_format); let normalized_api_format = normalize_api_format(candidate_api_format);
if normalized_api_format.is_empty() { if normalized_api_format.is_empty() {
return Ok(Vec::new()); return Ok((Vec::new(), false));
} }
let capability_mode = required_capability_match_mode(required_capability); let capability_mode = required_capability_match_mode(required_capability);
@@ -136,9 +167,10 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
} }
.map_err(|err| GatewayError::Internal(err.to_string()))?; .map_err(|err| GatewayError::Internal(err.to_string()))?;
let required_capabilities = build_required_capabilities_object(required_capability); let required_capabilities = build_required_capabilities_object(required_capability);
let mut all_attempts_blocked_by_auth_limit = !model_names.is_empty();
for global_model_name in model_names { for global_model_name in model_names {
let mut candidates = list_selectable_candidates( let (mut candidates, skipped_candidates) = collect_selectable_candidates_with_skip_reasons(
selection_row_source, selection_row_source,
runtime_state, runtime_state,
&normalized_api_format, &normalized_api_format,
@@ -149,6 +181,8 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
now_unix_secs, now_unix_secs,
) )
.await?; .await?;
all_attempts_blocked_by_auth_limit &=
is_exact_all_skipped_by_auth_limit(&candidates, &skipped_candidates);
match capability_mode { match capability_mode {
RequiredCapabilityMatchMode::Exclusive => { RequiredCapabilityMatchMode::Exclusive => {
let filtered = candidates let filtered = candidates
@@ -158,7 +192,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !filtered.is_empty() { if !filtered.is_empty() {
return Ok(filtered); return Ok((filtered, false));
} }
} }
RequiredCapabilityMatchMode::Compatible => { RequiredCapabilityMatchMode::Compatible => {
@@ -168,12 +202,12 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
candidates.sort_by_key(|candidate| { candidates.sort_by_key(|candidate| {
!candidate_supports_required_capability(candidate, required_capability) !candidate_supports_required_capability(candidate, required_capability)
}); });
return Ok(candidates); return Ok((candidates, false));
} }
} }
} }
Ok(Vec::new()) Ok((Vec::new(), all_attempts_blocked_by_auth_limit))
} }
fn required_capability_match_mode(required_capability: &str) -> RequiredCapabilityMatchMode { fn required_capability_match_mode(required_capability: &str) -> RequiredCapabilityMatchMode {

View File

@@ -31,6 +31,19 @@ pub(crate) struct SchedulerSkippedCandidate {
pub(crate) skip_reason: &'static str, pub(crate) skip_reason: &'static str,
} }
pub(super) const API_KEY_CONCURRENCY_LIMIT_SKIP_REASON: &str = "api_key_concurrency_limit_reached";
pub(super) fn is_exact_all_skipped_by_auth_limit(
selected: &[SchedulerMinimalCandidateSelectionCandidate],
skipped: &[SchedulerSkippedCandidate],
) -> bool {
selected.is_empty()
&& !skipped.is_empty()
&& skipped
.iter()
.all(|candidate| candidate.skip_reason == API_KEY_CONCURRENCY_LIMIT_SKIP_REASON)
}
pub(super) fn reorder_candidates_by_scheduler_health( pub(super) fn reorder_candidates_by_scheduler_health(
candidates: &mut [SchedulerMinimalCandidateSelectionCandidate], candidates: &mut [SchedulerMinimalCandidateSelectionCandidate],
provider_key_rpm_states: &BTreeMap<String, StoredProviderCatalogKey>, provider_key_rpm_states: &BTreeMap<String, StoredProviderCatalogKey>,
@@ -218,7 +231,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons(
.into_iter() .into_iter()
.map(|candidate| SchedulerSkippedCandidate { .map(|candidate| SchedulerSkippedCandidate {
candidate, candidate,
skip_reason: "api_key_concurrency_limit_reached", skip_reason: API_KEY_CONCURRENCY_LIMIT_SKIP_REASON,
}) })
.collect(), .collect(),
)); ));

View File

@@ -1,13 +1,21 @@
use std::sync::Arc; use std::sync::Arc;
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository; use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::quota::InMemoryProviderQuotaRepository; use aether_data::repository::quota::InMemoryProviderQuotaRepository;
use aether_data_contracts::repository::candidates::{
RequestCandidateStatus, StoredRequestCandidate,
};
use crate::data::GatewayDataState; use crate::data::GatewayDataState;
use crate::AppState; use crate::AppState;
use super::super::list_selectable_candidates_for_required_capability_without_requested_model; use super::super::{
use super::support::sample_row; list_selectable_candidates_for_required_capability_without_requested_model,
list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal,
};
use super::support::{sample_auth_snapshot, sample_provider, sample_row};
#[tokio::test] #[tokio::test]
async fn compatible_required_capability_prefers_matching_keys_without_hard_filtering() { async fn compatible_required_capability_prefers_matching_keys_without_hard_filtering() {
@@ -114,3 +122,78 @@ async fn exclusive_required_capability_keeps_hard_filtering_only_matching_keys()
assert_eq!(selection[0].provider_id, "provider-b"); assert_eq!(selection[0].provider_id, "provider-b");
assert_eq!(selection[0].key_id, "key-b"); assert_eq!(selection[0].key_id, "key-b");
} }
#[tokio::test]
async fn required_capability_reports_auth_limit_signal_when_every_model_is_blocked_by_api_key_concurrency(
) {
let mut candidate = sample_row();
candidate.key_capabilities = Some(serde_json::json!({"cache_1h": true}));
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
candidate,
]));
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1", None)],
Vec::new(),
Vec::new(),
));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
StoredRequestCandidate::new(
"cand-1".to_string(),
"req-1".to_string(),
Some("user-1".to_string()),
Some("api-key-1".to_string()),
None,
None,
0,
0,
Some("provider-1".to_string()),
Some("endpoint-1".to_string()),
Some("key-1".to_string()),
RequestCandidateStatus::Pending,
None,
false,
None,
None,
None,
None,
None,
None,
None,
95_000,
Some(95_000),
None,
)
.expect("candidate should build"),
]));
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
GatewayDataState::with_candidate_selection_provider_catalog_quota_and_request_candidates_for_tests(
candidates,
provider_catalog,
quotas,
request_candidates,
),
);
let mut auth_snapshot = sample_auth_snapshot("api-key-1");
auth_snapshot.api_key_concurrent_limit = Some(1);
let (selection, auth_limit_blocked) =
list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal(
state.data.as_ref(),
&state,
"openai:chat",
"cache_1h",
false,
Some(&auth_snapshot),
100,
)
.await
.expect("selection should succeed");
assert!(selection.is_empty());
assert!(auth_limit_blocked);
}

View File

@@ -23,7 +23,7 @@ use super::super::runtime::should_skip_provider_quota;
use super::super::selection::{ use super::super::selection::{
collect_selectable_candidates as collect_selectable_candidates_impl, collect_selectable_candidates as collect_selectable_candidates_impl,
collect_selectable_candidates_with_skip_reasons as collect_selectable_candidates_with_skip_reasons_impl, collect_selectable_candidates_with_skip_reasons as collect_selectable_candidates_with_skip_reasons_impl,
select_minimal_candidate as select_candidate_impl, is_exact_all_skipped_by_auth_limit, select_minimal_candidate as select_candidate_impl,
}; };
use super::support::{sample_auth_snapshot, sample_key, sample_provider, sample_row}; use super::support::{sample_auth_snapshot, sample_key, sample_provider, sample_row};
@@ -914,6 +914,23 @@ async fn returns_none_when_auth_api_key_concurrent_limit_is_reached() {
.expect("selection should succeed"); .expect("selection should succeed");
assert!(selected.is_none()); assert!(selected.is_none());
let (selected_candidates, skipped_candidates) =
collect_selectable_candidates_with_skip_reasons(
state.data.as_ref(),
&state,
"openai:chat",
"gpt-4.1",
false,
Some(&auth_snapshot),
100,
)
.await
.expect("selection should succeed");
assert!(is_exact_all_skipped_by_auth_limit(
&selected_candidates,
&skipped_candidates,
));
} }
#[tokio::test] #[tokio::test]
@@ -1138,6 +1155,7 @@ async fn exposes_runtime_skipped_candidates_with_skip_reasons() {
assert_eq!(skipped.len(), 1); assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].candidate.provider_id, "provider-a"); assert_eq!(skipped[0].candidate.provider_id, "provider-a");
assert_eq!(skipped[0].skip_reason, "key_circuit_open"); assert_eq!(skipped[0].skip_reason, "key_circuit_open");
assert!(!is_exact_all_skipped_by_auth_limit(&selected, &skipped));
} }
#[tokio::test] #[tokio::test]

View File

@@ -3,6 +3,9 @@ use super::{
to_bytes, Arc, Body, Json, Mutex, Request, Router, StatusCode, to_bytes, Arc, Body, Json, Mutex, Request, Router, StatusCode,
EXECUTION_PATH_EXECUTION_RUNTIME_SYNC, EXECUTION_PATH_HEADER, TRACE_ID_HEADER, EXECUTION_PATH_EXECUTION_RUNTIME_SYNC, EXECUTION_PATH_HEADER, TRACE_ID_HEADER,
}; };
use crate::constants::{
EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED, LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER,
};
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY}; use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data::repository::auth::{ use aether_data::repository::auth::{
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot, InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
@@ -14,7 +17,8 @@ use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping, StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
}; };
use aether_data_contracts::repository::candidates::{ use aether_data_contracts::repository::candidates::{
RequestCandidateReadRepository, RequestCandidateStatus, RequestCandidateReadRepository, RequestCandidateStatus, RequestCandidateWriteRepository,
UpsertRequestCandidateRecord,
}; };
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
@@ -535,6 +539,736 @@ async fn gateway_executes_openai_cli_sync_via_local_decision_gate_with_local_syn
upstream_handle.abort(); upstream_handle.abort();
} }
#[tokio::test]
async fn gateway_waits_for_api_key_concurrency_slot_then_executes_openai_cli_sync() {
fn hash_api_key(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("alice@example.com".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:cli"])),
Some(serde_json::json!(["gpt-5"])),
api_key_id.to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(4_102_444_800),
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:cli"])),
Some(serde_json::json!(["gpt-5"])),
)
.expect("auth snapshot should build")
}
fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow {
provider_id: "provider-openai-cli-local-limit-1".to_string(),
provider_name: "openai".to_string(),
provider_type: "custom".to_string(),
provider_priority: 10,
provider_is_active: true,
endpoint_id: "endpoint-openai-cli-local-limit-1".to_string(),
endpoint_api_format: "openai:cli".to_string(),
endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
endpoint_is_active: true,
key_id: "key-openai-cli-local-limit-1".to_string(),
key_name: "prod".to_string(),
key_auth_type: "api_key".to_string(),
key_is_active: true,
key_api_formats: Some(vec!["openai:cli".to_string()]),
key_allowed_models: None,
key_capabilities: None,
key_internal_priority: 5,
key_global_priority_by_format: Some(serde_json::json!({"openai:cli": 1})),
model_id: "model-openai-cli-local-limit-1".to_string(),
global_model_id: "global-model-openai-cli-local-limit-1".to_string(),
global_model_name: "gpt-5".to_string(),
global_model_mappings: None,
global_model_supports_streaming: Some(true),
model_provider_model_name: "gpt-5-upstream".to_string(),
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
name: "gpt-5-upstream".to_string(),
priority: 1,
api_formats: Some(vec!["openai:cli".to_string()]),
}]),
model_supports_streaming: Some(true),
model_is_active: true,
model_is_available: true,
}
}
fn sample_provider_catalog_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-openai-cli-local-limit-1".to_string(),
"openai".to_string(),
Some("https://example.com".to_string()),
"custom".to_string(),
)
.expect("provider should build")
.with_transport_fields(
true,
false,
false,
None,
Some(2),
None,
Some(20.0),
None,
None,
)
}
fn sample_provider_catalog_endpoint() -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
"endpoint-openai-cli-local-limit-1".to_string(),
"provider-openai-cli-local-limit-1".to_string(),
"openai:cli".to_string(),
Some("openai".to_string()),
Some("cli".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://api.openai.example/custom/v1/responses".to_string(),
None,
None,
Some(2),
Some("/custom/v1/responses".to_string()),
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
"key-openai-cli-local-limit-1".to_string(),
"provider-openai-cli-local-limit-1".to_string(),
"prod".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(serde_json::json!(["openai:cli"])),
encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
"sk-upstream-openai-cli-limit",
)
.expect("api key should encrypt"),
None,
None,
Some(serde_json::json!({"openai:cli": 1})),
None,
None,
None,
None,
)
.expect("key transport should build")
}
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
let public_hits = Arc::new(Mutex::new(0usize));
let public_hits_clone = Arc::clone(&public_hits);
let now_unix_ms = chrono::Utc::now().timestamp_millis().max(0);
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
aether_data_contracts::repository::candidates::StoredRequestCandidate::new(
"cand-pending-openai-cli-local-limit-1".to_string(),
"req-inflight-openai-cli-local-limit-1".to_string(),
Some("user-openai-cli-local-limit-123".to_string()),
Some("key-openai-cli-local-limit-123".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
0,
0,
Some("provider-openai-cli-local-limit-1".to_string()),
Some("endpoint-openai-cli-local-limit-1".to_string()),
Some("key-openai-cli-local-limit-1".to_string()),
RequestCandidateStatus::Pending,
None,
false,
None,
None,
None,
None,
None,
None,
None,
now_unix_ms,
Some(now_unix_ms),
None,
)
.expect("pending candidate should build"),
]));
let upstream = Router::new()
.route(
"/api/internal/gateway/resolve",
any(|_request: Request| async move {
Json(json!({
"action": "proxy_public",
"route_class": "ai_public",
"route_family": "openai",
"route_kind": "cli",
"auth_endpoint_signature": "openai:cli",
"execution_runtime_candidate": true,
"auth_context": {
"user_id": "user-openai-cli-local-limit-123",
"api_key_id": "key-openai-cli-local-limit-123",
"access_allowed": true
},
"public_path": "/v1/responses"
}))
}),
)
.route(
"/api/internal/gateway/decision-sync",
any(|_request: Request| async move { Json(json!({"action": "proxy_public"})) }),
)
.route(
"/api/internal/gateway/plan-sync",
any(|_request: Request| async move { Json(json!({"action": "proxy_public"})) }),
)
.route(
"/v1/responses",
any(move |_request: Request| {
let public_hits_inner = Arc::clone(&public_hits_clone);
async move {
*public_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::IM_A_TEAPOT, Body::from("public-route-hit"))
}
}),
);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |_request: Request| {
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
async move {
*execution_runtime_hits_inner
.lock()
.expect("mutex should lock") += 1;
Json(json!({
"request_id": "trace-openai-cli-local-limit-123",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"id": "resp-cli-local-limit-123",
"object": "response",
"model": "gpt-5-upstream",
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
}
},
"telemetry": {
"elapsed_ms": 21
}
}))
}
}),
);
let mut auth_snapshot = sample_auth_snapshot(
"key-openai-cli-local-limit-123",
"user-openai-cli-local-limit-123",
);
auth_snapshot.api_key_concurrent_limit = Some(1);
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-client-openai-cli-local-limit")),
auth_snapshot,
)]));
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
sample_candidate_row(),
]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_catalog_provider()],
vec![sample_provider_catalog_endpoint()],
vec![sample_provider_catalog_key()],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let gateway_state = build_state_with_execution_runtime_override(execution_runtime_url)
.with_data_state_for_tests(
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
auth_repository,
candidate_selection_repository,
provider_catalog_repository,
Arc::clone(&request_candidate_repository),
DEVELOPMENT_ENCRYPTION_KEY,
),
);
let gateway = build_router_with_state(gateway_state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let request_candidate_repository_for_release = Arc::clone(&request_candidate_repository);
let release_inflight_candidate = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
let pending = request_candidate_repository_for_release
.list_by_request_id("req-inflight-openai-cli-local-limit-1")
.await
.expect("inflight candidates should read")
.into_iter()
.find(|candidate| candidate.id == "cand-pending-openai-cli-local-limit-1")
.expect("seeded inflight candidate should exist");
request_candidate_repository_for_release
.upsert(UpsertRequestCandidateRecord {
id: pending.id,
request_id: pending.request_id,
user_id: pending.user_id,
api_key_id: pending.api_key_id,
username: pending.username,
api_key_name: pending.api_key_name,
candidate_index: pending.candidate_index,
retry_index: pending.retry_index,
provider_id: pending.provider_id,
endpoint_id: pending.endpoint_id,
key_id: pending.key_id,
status: RequestCandidateStatus::Success,
skip_reason: None,
is_cached: Some(false),
status_code: Some(200),
error_type: None,
error_message: None,
latency_ms: Some(1),
concurrent_requests: pending.concurrent_requests,
extra_data: pending.extra_data,
required_capabilities: pending.required_capabilities,
created_at_unix_ms: Some(pending.created_at_unix_ms),
started_at_unix_ms: pending.started_at_unix_ms,
finished_at_unix_ms: Some(pending.created_at_unix_ms.saturating_add(25)),
})
.await
.expect("inflight candidate should update");
});
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/responses"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-openai-cli-local-limit",
)
.header(TRACE_ID_HEADER, "trace-openai-cli-local-limit-123")
.body("{\"model\":\"gpt-5\",\"input\":\"hello\",\"store\":false}")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC)
);
assert_eq!(
response
.headers()
.get(LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER)
.and_then(|value| value.to_str().ok()),
None
);
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(payload["model"], "gpt-5-upstream");
let stored_candidates = request_candidate_repository
.list_by_request_id("trace-openai-cli-local-limit-123")
.await
.expect("request candidate trace should read");
assert_eq!(stored_candidates.len(), 1);
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Success);
assert_eq!(stored_candidates[0].skip_reason.as_deref(), None);
assert_eq!(
*execution_runtime_hits.lock().expect("mutex should lock"),
1
);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
release_inflight_candidate
.await
.expect("release task should complete");
gateway_handle.abort();
execution_runtime_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_concurrency_limited_after_wait_budget_expires_for_openai_cli_sync() {
fn hash_api_key(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("alice@example.com".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:cli"])),
Some(serde_json::json!(["gpt-5"])),
api_key_id.to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(4_102_444_800),
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:cli"])),
Some(serde_json::json!(["gpt-5"])),
)
.expect("auth snapshot should build")
}
fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow {
provider_id: "provider-openai-cli-local-timeout-1".to_string(),
provider_name: "openai".to_string(),
provider_type: "custom".to_string(),
provider_priority: 10,
provider_is_active: true,
endpoint_id: "endpoint-openai-cli-local-timeout-1".to_string(),
endpoint_api_format: "openai:cli".to_string(),
endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
endpoint_is_active: true,
key_id: "key-openai-cli-local-timeout-1".to_string(),
key_name: "prod".to_string(),
key_auth_type: "api_key".to_string(),
key_is_active: true,
key_api_formats: Some(vec!["openai:cli".to_string()]),
key_allowed_models: None,
key_capabilities: None,
key_internal_priority: 5,
key_global_priority_by_format: Some(serde_json::json!({"openai:cli": 1})),
model_id: "model-openai-cli-local-timeout-1".to_string(),
global_model_id: "global-model-openai-cli-local-timeout-1".to_string(),
global_model_name: "gpt-5".to_string(),
global_model_mappings: None,
global_model_supports_streaming: Some(true),
model_provider_model_name: "gpt-5-upstream".to_string(),
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
name: "gpt-5-upstream".to_string(),
priority: 1,
api_formats: Some(vec!["openai:cli".to_string()]),
}]),
model_supports_streaming: Some(true),
model_is_active: true,
model_is_available: true,
}
}
fn sample_provider_catalog_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-openai-cli-local-timeout-1".to_string(),
"openai".to_string(),
Some("https://example.com".to_string()),
"custom".to_string(),
)
.expect("provider should build")
.with_transport_fields(
true,
false,
false,
None,
Some(2),
None,
Some(20.0),
None,
None,
)
}
fn sample_provider_catalog_endpoint() -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
"endpoint-openai-cli-local-timeout-1".to_string(),
"provider-openai-cli-local-timeout-1".to_string(),
"openai:cli".to_string(),
Some("openai".to_string()),
Some("cli".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://api.openai.example/custom/v1/responses".to_string(),
None,
None,
Some(2),
Some("/custom/v1/responses".to_string()),
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
"key-openai-cli-local-timeout-1".to_string(),
"provider-openai-cli-local-timeout-1".to_string(),
"prod".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(serde_json::json!(["openai:cli"])),
encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
"sk-upstream-openai-cli-timeout",
)
.expect("api key should encrypt"),
None,
None,
Some(serde_json::json!({"openai:cli": 1})),
None,
None,
None,
None,
)
.expect("key transport should build")
}
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
let public_hits = Arc::new(Mutex::new(0usize));
let public_hits_clone = Arc::clone(&public_hits);
let now_unix_ms = chrono::Utc::now().timestamp_millis().max(0);
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
aether_data_contracts::repository::candidates::StoredRequestCandidate::new(
"cand-pending-openai-cli-local-timeout-1".to_string(),
"req-inflight-openai-cli-local-timeout-1".to_string(),
Some("user-openai-cli-local-timeout-123".to_string()),
Some("key-openai-cli-local-timeout-123".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
0,
0,
Some("provider-openai-cli-local-timeout-1".to_string()),
Some("endpoint-openai-cli-local-timeout-1".to_string()),
Some("key-openai-cli-local-timeout-1".to_string()),
RequestCandidateStatus::Pending,
None,
false,
None,
None,
None,
None,
None,
None,
None,
now_unix_ms,
Some(now_unix_ms),
None,
)
.expect("pending candidate should build"),
]));
let upstream = Router::new()
.route(
"/api/internal/gateway/resolve",
any(|_request: Request| async move {
Json(json!({
"action": "proxy_public",
"route_class": "ai_public",
"route_family": "openai",
"route_kind": "cli",
"auth_endpoint_signature": "openai:cli",
"execution_runtime_candidate": true,
"auth_context": {
"user_id": "user-openai-cli-local-timeout-123",
"api_key_id": "key-openai-cli-local-timeout-123",
"access_allowed": true
},
"public_path": "/v1/responses"
}))
}),
)
.route(
"/api/internal/gateway/decision-sync",
any(|_request: Request| async move { Json(json!({"action": "proxy_public"})) }),
)
.route(
"/api/internal/gateway/plan-sync",
any(|_request: Request| async move { Json(json!({"action": "proxy_public"})) }),
)
.route(
"/v1/responses",
any(move |_request: Request| {
let public_hits_inner = Arc::clone(&public_hits_clone);
async move {
*public_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::IM_A_TEAPOT, Body::from("public-route-hit"))
}
}),
);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |_request: Request| {
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
async move {
*execution_runtime_hits_inner
.lock()
.expect("mutex should lock") += 1;
Json(json!({
"request_id": "trace-openai-cli-local-timeout-123",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"id": "resp-cli-local-timeout-123",
"object": "response",
"model": "gpt-5-upstream",
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
}
},
"telemetry": {
"elapsed_ms": 21
}
}))
}
}),
);
let mut auth_snapshot = sample_auth_snapshot(
"key-openai-cli-local-timeout-123",
"user-openai-cli-local-timeout-123",
);
auth_snapshot.api_key_concurrent_limit = Some(1);
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-client-openai-cli-local-timeout")),
auth_snapshot,
)]));
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
sample_candidate_row(),
]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_catalog_provider()],
vec![sample_provider_catalog_endpoint()],
vec![sample_provider_catalog_key()],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let gateway_state = build_state_with_execution_runtime_override(execution_runtime_url)
.with_data_state_for_tests(
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
auth_repository,
candidate_selection_repository,
provider_catalog_repository,
Arc::clone(&request_candidate_repository),
DEVELOPMENT_ENCRYPTION_KEY,
),
);
let gateway = build_router_with_state(gateway_state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let started_at = std::time::Instant::now();
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/responses"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-openai-cli-local-timeout",
)
.header(TRACE_ID_HEADER, "trace-openai-cli-local-timeout-123")
.body("{\"model\":\"gpt-5\",\"input\":\"hello\",\"store\":false}")
.send()
.await
.expect("request should complete");
assert!(
started_at.elapsed() >= std::time::Duration::from_millis(100),
"request should wait for the bounded concurrency window before failing"
);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED)
);
assert_eq!(
response
.headers()
.get(LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER)
.and_then(|value| value.to_str().ok()),
Some("api_key_concurrency_limit_reached")
);
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(
payload["error"]["message"],
serde_json::Value::String("当前 API Key 并发请求数已达上限,请稍后重试".to_string())
);
let stored_candidates = request_candidate_repository
.list_by_request_id("trace-openai-cli-local-timeout-123")
.await
.expect("request candidate trace should read");
assert_eq!(stored_candidates.len(), 1);
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Skipped);
assert_eq!(
stored_candidates[0].skip_reason.as_deref(),
Some("api_key_concurrency_limit_reached")
);
assert_eq!(
*execution_runtime_hits.lock().expect("mutex should lock"),
0
);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
execution_runtime_handle.abort();
upstream_handle.abort();
}
#[tokio::test] #[tokio::test]
async fn gateway_returns_openai_cli_error_for_local_sync_failure() { async fn gateway_returns_openai_cli_error_for_local_sync_failure() {
fn hash_api_key(value: &str) -> String { fn hash_api_key(value: &str) -> String {

View File

@@ -447,6 +447,7 @@ async fn gateway_handles_admin_api_keys_create_locally_with_trusted_admin_princi
assert_eq!(payload["name"], json!("standalone-key")); assert_eq!(payload["name"], json!("standalone-key"));
assert_eq!(payload["is_standalone"], json!(true)); assert_eq!(payload["is_standalone"], json!(true));
assert_eq!(payload["rate_limit"], serde_json::Value::Null); assert_eq!(payload["rate_limit"], serde_json::Value::Null);
assert_eq!(payload["concurrent_limit"], serde_json::Value::Null);
assert_eq!(payload["allowed_providers"], json!(["openai"])); assert_eq!(payload["allowed_providers"], json!(["openai"]));
assert_eq!(payload["allowed_api_formats"], json!(["openai:chat"])); assert_eq!(payload["allowed_api_formats"], json!(["openai:chat"]));
assert_eq!(payload["allowed_models"], json!(["gpt-4.1"])); assert_eq!(payload["allowed_models"], json!(["gpt-4.1"]));
@@ -515,6 +516,7 @@ async fn gateway_handles_admin_api_keys_update_locally_with_trusted_admin_princi
.json(&json!({ .json(&json!({
"name": "renamed-key", "name": "renamed-key",
"rate_limit": null, "rate_limit": null,
"concurrent_limit": 12,
"allowed_providers": ["gemini"], "allowed_providers": ["gemini"],
"allowed_api_formats": ["gemini:chat"], "allowed_api_formats": ["gemini:chat"],
"allowed_models": ["gemini-2.5-pro"], "allowed_models": ["gemini-2.5-pro"],
@@ -531,6 +533,7 @@ async fn gateway_handles_admin_api_keys_update_locally_with_trusted_admin_princi
assert_eq!(payload["id"], json!("key-123")); assert_eq!(payload["id"], json!("key-123"));
assert_eq!(payload["name"], json!("renamed-key")); assert_eq!(payload["name"], json!("renamed-key"));
assert_eq!(payload["rate_limit"], serde_json::Value::Null); assert_eq!(payload["rate_limit"], serde_json::Value::Null);
assert_eq!(payload["concurrent_limit"], json!(12));
assert_eq!(payload["allowed_providers"], json!(["gemini"])); assert_eq!(payload["allowed_providers"], json!(["gemini"]));
assert_eq!(payload["allowed_api_formats"], json!(["gemini:chat"])); assert_eq!(payload["allowed_api_formats"], json!(["gemini:chat"]));
assert_eq!(payload["allowed_models"], json!(["gemini-2.5-pro"])); assert_eq!(payload["allowed_models"], json!(["gemini-2.5-pro"]));

View File

@@ -629,6 +629,7 @@ async fn gateway_handles_admin_user_api_key_routes_locally_with_trusted_admin_pr
.expect("json body should parse"); .expect("json body should parse");
assert_eq!(create_payload["name"], "new-key"); assert_eq!(create_payload["name"], "new-key");
assert_eq!(create_payload["rate_limit"], 90); assert_eq!(create_payload["rate_limit"], 90);
assert_eq!(create_payload["concurrent_limit"], serde_json::Value::Null);
assert_eq!( assert_eq!(
create_payload["message"], create_payload["message"],
"API Key创建成功请妥善保存完整密钥" "API Key创建成功请妥善保存完整密钥"
@@ -651,6 +652,7 @@ async fn gateway_handles_admin_user_api_key_routes_locally_with_trusted_admin_pr
.json(&json!({ .json(&json!({
"name": "renamed", "name": "renamed",
"rate_limit": 120, "rate_limit": 120,
"concurrent_limit": 9,
})) }))
.send() .send()
.await .await
@@ -664,6 +666,7 @@ async fn gateway_handles_admin_user_api_key_routes_locally_with_trusted_admin_pr
assert_eq!(update_payload["name"], "renamed"); assert_eq!(update_payload["name"], "renamed");
assert_eq!(update_payload["is_locked"], false); assert_eq!(update_payload["is_locked"], false);
assert_eq!(update_payload["rate_limit"], 120); assert_eq!(update_payload["rate_limit"], 120);
assert_eq!(update_payload["concurrent_limit"], 9);
assert_eq!(update_payload["message"], "API Key更新成功"); assert_eq!(update_payload["message"], "API Key更新成功");
let lock_response = client let lock_response = client

View File

@@ -6206,6 +6206,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
.to_string(); .to_string();
assert_eq!(create_payload["name"], "writer-key"); assert_eq!(create_payload["name"], "writer-key");
assert_eq!(create_payload["rate_limit"], 120); assert_eq!(create_payload["rate_limit"], 120);
assert_eq!(create_payload["concurrent_limit"], serde_json::Value::Null);
assert_eq!(create_payload["message"], "API密钥创建成功"); assert_eq!(create_payload["message"], "API密钥创建成功");
assert!(create_payload["key"] assert!(create_payload["key"]
.as_str() .as_str()
@@ -6219,7 +6220,8 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
.header("user-agent", "AetherTest/1.0") .header("user-agent", "AetherTest/1.0")
.json(&json!({ .json(&json!({
"name": "writer-key-renamed", "name": "writer-key-renamed",
"rate_limit": 30 "rate_limit": 30,
"concurrent_limit": 4
})) }))
.send() .send()
.await .await
@@ -6231,6 +6233,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
.expect("json body should parse"); .expect("json body should parse");
assert_eq!(update_payload["name"], "writer-key-renamed"); assert_eq!(update_payload["name"], "writer-key-renamed");
assert_eq!(update_payload["rate_limit"], 30); assert_eq!(update_payload["rate_limit"], 30);
assert_eq!(update_payload["concurrent_limit"], 4);
assert_eq!(update_payload["message"], "API密钥已更新"); assert_eq!(update_payload["message"], "API密钥已更新");
let toggle_response = client let toggle_response = client
@@ -6308,6 +6311,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
detail_payload["allowed_providers"], detail_payload["allowed_providers"],
json!(["provider-openai"]) json!(["provider-openai"])
); );
assert_eq!(detail_payload["concurrent_limit"], 4);
assert_eq!(detail_payload["force_capabilities"], json!({})); assert_eq!(detail_payload["force_capabilities"], json!({}));
let delete_response = client let delete_response = client

View File

@@ -1333,14 +1333,14 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
stored_usage.user_id.as_deref(), stored_usage.user_id.as_deref(),
Some("user-claude-cli-usage-local-miss-1") Some("user-claude-cli-usage-local-miss-1")
); );
assert_eq!(stored_usage.provider_name, "RightCode"); assert_eq!(stored_usage.provider_name, "claude");
assert_eq!(stored_usage.model, "gpt-5.4"); assert_eq!(stored_usage.model, "gpt-5.4");
assert_eq!(stored_usage.api_format.as_deref(), Some("claude:cli")); assert_eq!(stored_usage.api_format.as_deref(), Some("claude:cli"));
assert_eq!( assert_eq!(
stored_usage.endpoint_api_format.as_deref(), stored_usage.endpoint_api_format.as_deref(),
Some("openai:cli") Some("claude:cli")
); );
assert_eq!(stored_usage.routing_key_name(), Some("codex")); assert_eq!(stored_usage.routing_key_name(), None);
assert_eq!(stored_usage.routing_planner_kind(), Some("claude_cli_sync")); assert_eq!(stored_usage.routing_planner_kind(), Some("claude_cli_sync"));
assert_eq!(stored_usage.routing_route_family(), Some("claude")); assert_eq!(stored_usage.routing_route_family(), Some("claude"));
assert_eq!(stored_usage.routing_route_kind(), Some("cli")); assert_eq!(stored_usage.routing_route_kind(), Some("cli"));
@@ -1375,10 +1375,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
stored_candidates[0].skip_reason.as_deref(), stored_candidates[0].skip_reason.as_deref(),
Some("format_conversion_disabled") Some("format_conversion_disabled")
); );
assert_eq!( assert_eq!(stored_usage.routing_candidate_id(), None);
stored_usage.routing_candidate_id(),
Some(stored_candidates[0].id.as_str())
);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();

View File

@@ -417,7 +417,7 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
api_key_is_locked: false, api_key_is_locked: false,
api_key_is_standalone: false, api_key_is_standalone: false,
api_key_rate_limit: Some(record.rate_limit), api_key_rate_limit: Some(record.rate_limit),
api_key_concurrent_limit: Some(record.concurrent_limit), api_key_concurrent_limit: record.concurrent_limit,
api_key_expires_at_unix_secs: record.expires_at_unix_secs, api_key_expires_at_unix_secs: record.expires_at_unix_secs,
api_key_allowed_providers: record.allowed_providers.clone(), api_key_allowed_providers: record.allowed_providers.clone(),
api_key_allowed_api_formats: record.allowed_api_formats.clone(), api_key_allowed_api_formats: record.allowed_api_formats.clone(),
@@ -445,7 +445,7 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
false, false,
false, false,
Some(record.rate_limit), Some(record.rate_limit),
Some(record.concurrent_limit), record.concurrent_limit,
record.expires_at_unix_secs.map(|value| value as i64), record.expires_at_unix_secs.map(|value| value as i64),
record record
.allowed_providers .allowed_providers
@@ -481,7 +481,7 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
.as_ref() .as_ref()
.map(|value| serde_json::json!(value)), .map(|value| serde_json::json!(value)),
Some(record.rate_limit), Some(record.rate_limit),
Some(record.concurrent_limit), record.concurrent_limit,
record.force_capabilities, record.force_capabilities,
record.is_active, record.is_active,
record.expires_at_unix_secs.map(|value| value as i64), record.expires_at_unix_secs.map(|value| value as i64),
@@ -537,7 +537,7 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
api_key_is_locked: false, api_key_is_locked: false,
api_key_is_standalone: true, api_key_is_standalone: true,
api_key_rate_limit: record.rate_limit, api_key_rate_limit: record.rate_limit,
api_key_concurrent_limit: Some(record.concurrent_limit), api_key_concurrent_limit: record.concurrent_limit,
api_key_expires_at_unix_secs: record.expires_at_unix_secs, api_key_expires_at_unix_secs: record.expires_at_unix_secs,
api_key_allowed_providers: record.allowed_providers.clone(), api_key_allowed_providers: record.allowed_providers.clone(),
api_key_allowed_api_formats: record.allowed_api_formats.clone(), api_key_allowed_api_formats: record.allowed_api_formats.clone(),
@@ -565,7 +565,7 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
false, false,
true, true,
record.rate_limit, record.rate_limit,
Some(record.concurrent_limit), record.concurrent_limit,
record.expires_at_unix_secs.map(|value| value as i64), record.expires_at_unix_secs.map(|value| value as i64),
record record
.allowed_providers .allowed_providers
@@ -601,7 +601,7 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
.as_ref() .as_ref()
.map(|value| serde_json::json!(value)), .map(|value| serde_json::json!(value)),
record.rate_limit, record.rate_limit,
Some(record.concurrent_limit), record.concurrent_limit,
record.force_capabilities, record.force_capabilities,
record.is_active, record.is_active,
record.expires_at_unix_secs.map(|value| value as i64), record.expires_at_unix_secs.map(|value| value as i64),
@@ -653,6 +653,14 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
export.rate_limit = Some(rate_limit); export.rate_limit = Some(rate_limit);
} }
} }
if let Some(concurrent_limit) = record.concurrent_limit {
if let Some(snapshot) = index.by_api_key_id.get_mut(&record.api_key_id) {
snapshot.api_key_concurrent_limit = Some(concurrent_limit);
}
if let Some(export) = index.export_by_api_key_id.get_mut(&record.api_key_id) {
export.concurrent_limit = Some(concurrent_limit);
}
}
Ok(index.export_by_api_key_id.get(&record.api_key_id).cloned()) Ok(index.export_by_api_key_id.get(&record.api_key_id).cloned())
} }
@@ -686,6 +694,14 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
export.rate_limit = record.rate_limit; export.rate_limit = record.rate_limit;
} }
} }
if record.concurrent_limit_present {
if let Some(snapshot) = index.by_api_key_id.get_mut(&record.api_key_id) {
snapshot.api_key_concurrent_limit = record.concurrent_limit;
}
if let Some(export) = index.export_by_api_key_id.get_mut(&record.api_key_id) {
export.concurrent_limit = record.concurrent_limit;
}
}
if let Some(allowed_providers) = record.allowed_providers { if let Some(allowed_providers) = record.allowed_providers {
if let Some(snapshot) = index.by_api_key_id.get_mut(&record.api_key_id) { if let Some(snapshot) = index.by_api_key_id.get_mut(&record.api_key_id) {
snapshot.api_key_allowed_providers = allowed_providers.clone(); snapshot.api_key_allowed_providers = allowed_providers.clone();
@@ -892,6 +908,7 @@ mod tests {
use crate::repository::auth::{ use crate::repository::auth::{
AuthApiKeyLookupKey, AuthApiKeyReadRepository, AuthApiKeyWriteRepository, AuthApiKeyLookupKey, AuthApiKeyReadRepository, AuthApiKeyWriteRepository,
StandaloneApiKeyExportListQuery, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot, StandaloneApiKeyExportListQuery, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
UpdateStandaloneApiKeyBasicRecord, UpdateUserApiKeyBasicRecord,
}; };
fn sample_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot { fn sample_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
@@ -1074,4 +1091,70 @@ mod tests {
1 1
); );
} }
#[tokio::test]
async fn update_user_api_key_basic_updates_concurrent_limit() {
let repository = InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("hash-1".to_string()),
sample_snapshot("key-1", "user-1"),
)]);
let updated = repository
.update_user_api_key_basic(UpdateUserApiKeyBasicRecord {
user_id: "user-1".to_string(),
api_key_id: "key-1".to_string(),
name: None,
rate_limit: None,
concurrent_limit: Some(11),
})
.await
.expect("update should succeed")
.expect("record should exist");
assert_eq!(updated.concurrent_limit, Some(11));
let snapshot = repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("key-1"))
.await
.expect("find should succeed")
.expect("snapshot should exist");
assert_eq!(snapshot.api_key_concurrent_limit, Some(11));
}
#[tokio::test]
async fn update_standalone_api_key_basic_updates_concurrent_limit_when_present() {
let mut standalone = sample_snapshot("key-standalone", "admin-1");
standalone.api_key_is_standalone = true;
let repository = InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("hash-standalone".to_string()),
standalone,
)]);
let updated = repository
.update_standalone_api_key_basic(UpdateStandaloneApiKeyBasicRecord {
api_key_id: "key-standalone".to_string(),
name: None,
rate_limit_present: false,
rate_limit: None,
concurrent_limit_present: true,
concurrent_limit: Some(13),
allowed_providers: None,
allowed_api_formats: None,
allowed_models: None,
expires_at_present: false,
expires_at_unix_secs: None,
auto_delete_on_expiry_present: false,
auto_delete_on_expiry: false,
})
.await
.expect("update should succeed")
.expect("record should exist");
assert_eq!(updated.concurrent_limit, Some(13));
let snapshot = repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("key-standalone"))
.await
.expect("find should succeed")
.expect("snapshot should exist");
assert_eq!(snapshot.api_key_concurrent_limit, Some(13));
}
} }

View File

@@ -464,6 +464,7 @@ UPDATE api_keys
SET SET
name = COALESCE($3, name), name = COALESCE($3, name),
rate_limit = COALESCE($4, rate_limit), rate_limit = COALESCE($4, rate_limit),
concurrent_limit = COALESCE($5, concurrent_limit),
updated_at = NOW() updated_at = NOW()
WHERE user_id = $1 WHERE user_id = $1
AND id = $2 AND id = $2
@@ -493,11 +494,12 @@ UPDATE api_keys
SET SET
name = COALESCE($2, name), name = COALESCE($2, name),
rate_limit = CASE WHEN $3 THEN $4 ELSE rate_limit END, rate_limit = CASE WHEN $3 THEN $4 ELSE rate_limit END,
allowed_providers = CASE WHEN $5 THEN $6::json ELSE allowed_providers END, concurrent_limit = CASE WHEN $5 THEN $6 ELSE concurrent_limit END,
allowed_api_formats = CASE WHEN $7 THEN $8::json ELSE allowed_api_formats END, allowed_providers = CASE WHEN $7 THEN $8::json ELSE allowed_providers END,
allowed_models = CASE WHEN $9 THEN $10::json ELSE allowed_models END, allowed_api_formats = CASE WHEN $9 THEN $10::json ELSE allowed_api_formats END,
expires_at = CASE WHEN $11 THEN $12 ELSE expires_at END, allowed_models = CASE WHEN $11 THEN $12::json ELSE allowed_models END,
auto_delete_on_expiry = CASE WHEN $13 THEN $14 ELSE auto_delete_on_expiry END, expires_at = CASE WHEN $13 THEN $14 ELSE expires_at END,
auto_delete_on_expiry = CASE WHEN $15 THEN $16 ELSE auto_delete_on_expiry END,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
AND is_standalone = TRUE AND is_standalone = TRUE
@@ -1105,6 +1107,7 @@ impl AuthApiKeyWriteRepository for SqlxAuthApiKeySnapshotReadRepository {
.bind(record.api_key_id) .bind(record.api_key_id)
.bind(record.name) .bind(record.name)
.bind(record.rate_limit) .bind(record.rate_limit)
.bind(record.concurrent_limit)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_postgres_err()?; .map_postgres_err()?;
@@ -1149,6 +1152,8 @@ impl AuthApiKeyWriteRepository for SqlxAuthApiKeySnapshotReadRepository {
.bind(record.name) .bind(record.name)
.bind(record.rate_limit_present) .bind(record.rate_limit_present)
.bind(record.rate_limit) .bind(record.rate_limit)
.bind(record.concurrent_limit_present)
.bind(record.concurrent_limit)
.bind(record.allowed_providers.is_some()) .bind(record.allowed_providers.is_some())
.bind(allowed_providers) .bind(allowed_providers)
.bind(record.allowed_api_formats.is_some()) .bind(record.allowed_api_formats.is_some())
@@ -1363,18 +1368,20 @@ mod tests {
#[test] #[test]
fn update_standalone_api_key_basic_sql_casts_json_case_values() { fn update_standalone_api_key_basic_sql_casts_json_case_values() {
assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL
.contains("allowed_providers = CASE WHEN $5 THEN $6::json ELSE allowed_providers END")); .contains("concurrent_limit = CASE WHEN $5 THEN $6 ELSE concurrent_limit END"));
assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL
.contains("allowed_providers = CASE WHEN $7 THEN $8::json ELSE allowed_providers END"));
assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL.contains( assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL.contains(
"allowed_api_formats = CASE WHEN $7 THEN $8::json ELSE allowed_api_formats END" "allowed_api_formats = CASE WHEN $9 THEN $10::json ELSE allowed_api_formats END"
)); ));
assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL
.contains("allowed_models = CASE WHEN $9 THEN $10::json ELSE allowed_models END")); .contains("allowed_models = CASE WHEN $11 THEN $12::json ELSE allowed_models END"));
assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL
.contains("rate_limit = CASE WHEN $3 THEN $4 ELSE rate_limit END")); .contains("rate_limit = CASE WHEN $3 THEN $4 ELSE rate_limit END"));
assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL
.contains("expires_at = CASE WHEN $11 THEN $12 ELSE expires_at END")); .contains("expires_at = CASE WHEN $13 THEN $14 ELSE expires_at END"));
assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL.contains( assert!(UPDATE_STANDALONE_API_KEY_BASIC_SQL.contains(
"auto_delete_on_expiry = CASE WHEN $13 THEN $14 ELSE auto_delete_on_expiry END" "auto_delete_on_expiry = CASE WHEN $15 THEN $16 ELSE auto_delete_on_expiry END"
)); ));
} }

View File

@@ -363,7 +363,7 @@ pub struct CreateUserApiKeyRecord {
pub allowed_api_formats: Option<Vec<String>>, pub allowed_api_formats: Option<Vec<String>>,
pub allowed_models: Option<Vec<String>>, pub allowed_models: Option<Vec<String>>,
pub rate_limit: i32, pub rate_limit: i32,
pub concurrent_limit: i32, pub concurrent_limit: Option<i32>,
pub force_capabilities: Option<serde_json::Value>, pub force_capabilities: Option<serde_json::Value>,
pub is_active: bool, pub is_active: bool,
pub expires_at_unix_secs: Option<u64>, pub expires_at_unix_secs: Option<u64>,
@@ -378,6 +378,7 @@ pub struct UpdateUserApiKeyBasicRecord {
pub api_key_id: String, pub api_key_id: String,
pub name: Option<String>, pub name: Option<String>,
pub rate_limit: Option<i32>, pub rate_limit: Option<i32>,
pub concurrent_limit: Option<i32>,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -391,7 +392,7 @@ pub struct CreateStandaloneApiKeyRecord {
pub allowed_api_formats: Option<Vec<String>>, pub allowed_api_formats: Option<Vec<String>>,
pub allowed_models: Option<Vec<String>>, pub allowed_models: Option<Vec<String>>,
pub rate_limit: Option<i32>, pub rate_limit: Option<i32>,
pub concurrent_limit: i32, pub concurrent_limit: Option<i32>,
pub force_capabilities: Option<serde_json::Value>, pub force_capabilities: Option<serde_json::Value>,
pub is_active: bool, pub is_active: bool,
pub expires_at_unix_secs: Option<u64>, pub expires_at_unix_secs: Option<u64>,
@@ -406,6 +407,8 @@ pub struct UpdateStandaloneApiKeyBasicRecord {
pub name: Option<String>, pub name: Option<String>,
pub rate_limit_present: bool, pub rate_limit_present: bool,
pub rate_limit: Option<i32>, pub rate_limit: Option<i32>,
pub concurrent_limit_present: bool,
pub concurrent_limit: Option<i32>,
pub allowed_providers: Option<Option<Vec<String>>>, pub allowed_providers: Option<Option<Vec<String>>>,
pub allowed_api_formats: Option<Option<Vec<String>>>, pub allowed_api_formats: Option<Option<Vec<String>>>,
pub allowed_models: Option<Option<Vec<String>>>, pub allowed_models: Option<Option<Vec<String>>>,

View File

@@ -1,6 +1,5 @@
import apiClient from './client' import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache' import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { AxiosRequestConfig } from 'axios'
import type { BillingSummary } from './auth' import type { BillingSummary } from './auth'
// LDAP 配置导出结构 // LDAP 配置导出结构
@@ -353,6 +352,7 @@ export interface AdminApiKey {
total_tokens?: number | null total_tokens?: number | null
total_cost_usd?: number total_cost_usd?: number
rate_limit?: number | null // null = 跟随系统默认0 = 不限制 rate_limit?: number | null // null = 跟随系统默认0 = 不限制
concurrent_limit?: number | null // null = 跟随系统默认0 = 不限制
allowed_providers?: string[] | null // 允许的提供商列表 allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表 allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表 allowed_models?: string[] | null // 允许的模型列表
@@ -370,6 +370,7 @@ export interface CreateStandaloneApiKeyRequest {
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
allowed_models?: string[] | null allowed_models?: string[] | null
rate_limit?: number | null // null = 跟随系统默认0 = 不限制 rate_limit?: number | null // null = 跟随系统默认0 = 不限制
concurrent_limit?: number | null // null = 跟随系统默认0 = 不限制
expires_at?: string | null // RFC3339 时间null = 永不过期 expires_at?: string | null // RFC3339 时间null = 永不过期
initial_balance_usd: number | null // 初始余额null = 无限制 initial_balance_usd: number | null // 初始余额null = 无限制
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值 unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
@@ -594,7 +595,7 @@ export const adminApi = {
key: string, key: string,
value: unknown, value: unknown,
description?: string, description?: string,
requestConfig?: AxiosRequestConfig, requestConfig?: Parameters<typeof apiClient.put>[2],
): Promise<{ key: string; value: unknown; description?: string }> { ): Promise<{ key: string; value: unknown; description?: string }> {
const response = await apiClient.put<{ key: string; value: unknown; description?: string }>( const response = await apiClient.put<{ key: string; value: unknown; description?: string }>(
`/api/admin/system/configs/${key}`, `/api/admin/system/configs/${key}`,

View File

@@ -159,6 +159,7 @@ export interface ApiKey {
total_requests?: number total_requests?: number
total_cost_usd?: number total_cost_usd?: number
rate_limit?: number | null rate_limit?: number | null
concurrent_limit?: number | null
allowed_providers?: ProviderConfig[] allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置 force_capabilities?: Record<string, boolean> | null // 强制能力配置
} }
@@ -220,7 +221,7 @@ export const meApi = {
return response.data return response.data
}, },
async createApiKey(data: { name: string; rate_limit?: number }): Promise<ApiKey> { async createApiKey(data: { name: string; rate_limit?: number | null; concurrent_limit?: number | null }): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data) const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
return response.data return response.data
}, },
@@ -253,7 +254,7 @@ export const meApi = {
async updateApiKey( async updateApiKey(
keyId: string, keyId: string,
data: { name?: string; rate_limit?: number | null } data: { name?: string; rate_limit?: number | null; concurrent_limit?: number | null }
): Promise<ApiKey & { message: string }> { ): Promise<ApiKey & { message: string }> {
const response = await apiClient.put<ApiKey & { message: string }>( const response = await apiClient.put<ApiKey & { message: string }>(
`/api/users/me/api-keys/${keyId}`, `/api/users/me/api-keys/${keyId}`,

View File

@@ -1,4 +1,5 @@
import apiClient from './client' import apiClient from './client'
import type { UserSession as SessionRecord } from '@/types/session'
export interface User { export interface User {
id: string // UUID id: string // UUID
@@ -53,6 +54,7 @@ export interface ApiKey {
is_locked: boolean // 管理员锁定标志 is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key is_standalone: boolean // 是否为独立余额Key
rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认 rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认
concurrent_limit?: number | null // 普通Key: 0 = 不限制并发,历史 null 兼容
total_requests?: number // 总请求数 total_requests?: number // 总请求数
total_cost_usd?: number // 总费用 total_cost_usd?: number // 总费用
} }
@@ -60,9 +62,10 @@ export interface ApiKey {
export interface UpsertUserApiKeyRequest { export interface UpsertUserApiKeyRequest {
name?: string name?: string
rate_limit?: number | null rate_limit?: number | null
concurrent_limit?: number | null
} }
export type { UserSession } from '@/types/session' export type UserSession = SessionRecord
export const usersApi = { export const usersApi = {
async getAllUsers(): Promise<User[]> { async getAllUsers(): Promise<User[]> {
@@ -94,8 +97,8 @@ export const usersApi = {
return response.data.api_keys return response.data.api_keys
}, },
async getUserSessions(userId: string): Promise<UserSession[]> { async getUserSessions(userId: string): Promise<SessionRecord[]> {
const response = await apiClient.get<UserSession[]>(`/api/admin/users/${userId}/sessions`) const response = await apiClient.get<SessionRecord[]>(`/api/admin/users/${userId}/sessions`)
return response.data return response.data
}, },

View File

@@ -203,6 +203,39 @@
</div> </div>
</div> </div>
<div class="space-y-2">
<Label
for="form-concurrent-limit"
class="text-sm font-medium"
>并发限制</Label>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<Input
v-if="!form.concurrent_limit_inherited"
id="form-concurrent-limit"
:model-value="form.concurrent_limit ?? ''"
type="number"
min="0"
max="10000"
placeholder="0 = 不限制"
class="h-10"
@update:model-value="(v) => form.concurrent_limit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<span
v-else
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
>不限制</span>
</div>
<Switch
v-model="form.concurrent_limit_inherited"
class="shrink-0"
/>
</div>
<p class="text-xs text-muted-foreground">
留空表示不限制,填 0 也表示不限制并发
</p>
</div>
<!-- 额度 --> <!-- 额度 -->
<div class="space-y-2"> <div class="space-y-2">
<Label class="text-sm font-medium">额度</Label> <Label class="text-sm font-medium">额度</Label>
@@ -288,6 +321,7 @@ export interface StandaloneKeyFormData {
unlimited_balance?: boolean unlimited_balance?: boolean
expires_at?: string // ISO 日期字符串,如 "2025-12-31"undefined = 永不过期 expires_at?: string // ISO 日期字符串,如 "2025-12-31"undefined = 永不过期
rate_limit?: number | null rate_limit?: number | null
concurrent_limit?: number | null
auto_delete_on_expiry: boolean auto_delete_on_expiry: boolean
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
@@ -303,6 +337,8 @@ interface StandaloneKeyFormState {
expires_at?: string expires_at?: string
rate_limit_inherited: boolean rate_limit_inherited: boolean
rate_limit?: number rate_limit?: number
concurrent_limit_inherited: boolean
concurrent_limit?: number
auto_delete_on_expiry: boolean auto_delete_on_expiry: boolean
provider_unrestricted: boolean provider_unrestricted: boolean
api_format_unrestricted: boolean api_format_unrestricted: boolean
@@ -358,6 +394,8 @@ const form = ref<StandaloneKeyFormState>({
expires_at: undefined, expires_at: undefined,
rate_limit_inherited: true, rate_limit_inherited: true,
rate_limit: undefined, rate_limit: undefined,
concurrent_limit_inherited: true,
concurrent_limit: undefined,
auto_delete_on_expiry: false, auto_delete_on_expiry: false,
provider_unrestricted: true, provider_unrestricted: true,
api_format_unrestricted: true, api_format_unrestricted: true,
@@ -402,6 +440,8 @@ function resetForm() {
expires_at: undefined, expires_at: undefined,
rate_limit_inherited: true, rate_limit_inherited: true,
rate_limit: undefined, rate_limit: undefined,
concurrent_limit_inherited: true,
concurrent_limit: undefined,
auto_delete_on_expiry: false, auto_delete_on_expiry: false,
provider_unrestricted: true, provider_unrestricted: true,
api_format_unrestricted: true, api_format_unrestricted: true,
@@ -423,6 +463,8 @@ function loadKeyData() {
expires_at: props.apiKey.expires_at, expires_at: props.apiKey.expires_at,
rate_limit_inherited: props.apiKey.rate_limit == null, rate_limit_inherited: props.apiKey.rate_limit == null,
rate_limit: props.apiKey.rate_limit ?? undefined, rate_limit: props.apiKey.rate_limit ?? undefined,
concurrent_limit_inherited: props.apiKey.concurrent_limit == null,
concurrent_limit: props.apiKey.concurrent_limit ?? undefined,
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry, auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
provider_unrestricted: props.apiKey.allowed_providers == null, provider_unrestricted: props.apiKey.allowed_providers == null,
api_format_unrestricted: props.apiKey.allowed_api_formats == null, api_format_unrestricted: props.apiKey.allowed_api_formats == null,
@@ -473,6 +515,7 @@ function handleSubmit() {
unlimited_balance: form.value.unlimited_balance, unlimited_balance: form.value.unlimited_balance,
expires_at: form.value.expires_at, expires_at: form.value.expires_at,
rate_limit: form.value.rate_limit_inherited ? null : (form.value.rate_limit ?? 0), rate_limit: form.value.rate_limit_inherited ? null : (form.value.rate_limit ?? 0),
concurrent_limit: form.value.concurrent_limit_inherited ? null : (form.value.concurrent_limit ?? 0),
auto_delete_on_expiry: form.value.auto_delete_on_expiry, auto_delete_on_expiry: form.value.auto_delete_on_expiry,
allowed_providers: form.value.provider_unrestricted ? null : [...form.value.allowed_providers], allowed_providers: form.value.provider_unrestricted ? null : [...form.value.allowed_providers],
allowed_api_formats: form.value.api_format_unrestricted ? null : [...form.value.allowed_api_formats], allowed_api_formats: form.value.api_format_unrestricted ? null : [...form.value.allowed_api_formats],
@@ -503,6 +546,15 @@ watch(
} }
) )
watch(
() => form.value.concurrent_limit_inherited,
(inherited) => {
if (!inherited && form.value.concurrent_limit == null) {
form.value.concurrent_limit = 0
}
}
)
defineExpose({ defineExpose({
setSaving setSaving
}) })

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { buildUserApiKeyMutationPayload } from '@/features/api-keys/utils/userKeyPayload'
describe('userKeyPayload', () => {
it('omits concurrent_limit when the field is left blank', () => {
expect(buildUserApiKeyMutationPayload({
name: 'writer-key',
rate_limit: 30,
concurrent_limit: undefined,
})).toEqual({
name: 'writer-key',
rate_limit: 30,
})
})
it('keeps explicit unlimited concurrent_limit values', () => {
expect(buildUserApiKeyMutationPayload({
name: 'writer-key',
rate_limit: undefined,
concurrent_limit: 0,
})).toEqual({
name: 'writer-key',
rate_limit: 0,
concurrent_limit: 0,
})
})
it('keeps positive concurrent_limit values', () => {
expect(buildUserApiKeyMutationPayload({
name: 'writer-key',
rate_limit: 15,
concurrent_limit: 4,
})).toEqual({
name: 'writer-key',
rate_limit: 15,
concurrent_limit: 4,
})
})
})

View File

@@ -0,0 +1,21 @@
export interface UserApiKeyMutationPayload {
name: string
rate_limit: number
concurrent_limit?: number
}
interface BuildUserApiKeyMutationPayloadInput {
name: string
rate_limit?: number
concurrent_limit?: number
}
export function buildUserApiKeyMutationPayload(
input: BuildUserApiKeyMutationPayloadInput,
): UserApiKeyMutationPayload {
return {
name: input.name,
rate_limit: input.rate_limit ?? 0,
...(input.concurrent_limit === undefined ? {} : { concurrent_limit: input.concurrent_limit }),
}
}

View File

@@ -106,7 +106,7 @@
钱包 钱包
</TableHead> </TableHead>
<TableHead class="w-[190px] h-12 font-semibold"> <TableHead class="w-[190px] h-12 font-semibold">
统计/ 统计/
</TableHead> </TableHead>
<TableHead class="w-[110px] h-12 font-semibold"> <TableHead class="w-[110px] h-12 font-semibold">
有效期 有效期
@@ -239,6 +239,22 @@
{{ formatRateLimitInheritable(apiKey.rate_limit) }} {{ formatRateLimitInheritable(apiKey.rate_limit) }}
</span> </span>
</div> </div>
<div class="flex items-center gap-1 text-muted-foreground">
<span>并发:</span>
<Badge
v-if="isConcurrentLimitInherited(apiKey.concurrent_limit) || isConcurrentLimitUnlimited(apiKey.concurrent_limit)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatConcurrentLimitInheritable(apiKey.concurrent_limit) }}
</Badge>
<span
v-else
class="font-medium text-foreground"
>
{{ formatConcurrentLimitInheritable(apiKey.concurrent_limit) }}
</span>
</div>
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-4"> <TableCell class="py-4">
@@ -409,6 +425,12 @@
> >
{{ formatRateLimitInheritable(apiKey.rate_limit) }} {{ formatRateLimitInheritable(apiKey.rate_limit) }}
</Badge> </Badge>
<Badge
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatConcurrentLimitInheritable(apiKey.concurrent_limit) }}
</Badge>
<Badge <Badge
v-if="apiKey.auto_delete_on_expiry" v-if="apiKey.auto_delete_on_expiry"
variant="secondary" variant="secondary"
@@ -920,6 +942,7 @@ function editApiKey(apiKey: AdminApiKey) {
unlimited_balance: isApiKeyUnlimited(apiKey), unlimited_balance: isApiKeyUnlimited(apiKey),
expires_at: expiresAt, expires_at: expiresAt,
rate_limit: apiKey.rate_limit ?? undefined, rate_limit: apiKey.rate_limit ?? undefined,
concurrent_limit: apiKey.concurrent_limit ?? undefined,
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false, auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
allowed_providers: apiKey.allowed_providers == null ? null : [...apiKey.allowed_providers], allowed_providers: apiKey.allowed_providers == null ? null : [...apiKey.allowed_providers],
allowed_api_formats: apiKey.allowed_api_formats == null ? null : [...apiKey.allowed_api_formats], allowed_api_formats: apiKey.allowed_api_formats == null ? null : [...apiKey.allowed_api_formats],
@@ -961,6 +984,20 @@ function formatApiKeyTotalTokens(apiKey: AdminApiKey): string {
return formatTokens(apiKey.total_tokens) return formatTokens(apiKey.total_tokens)
} }
function formatConcurrentLimitInheritable(concurrentLimit?: number | null): string {
if (concurrentLimit == null) return '不限并发'
if (concurrentLimit === 0) return '不限并发'
return `${concurrentLimit} 并发`
}
function isConcurrentLimitInherited(concurrentLimit?: number | null): boolean {
return concurrentLimit == null
}
function isConcurrentLimitUnlimited(concurrentLimit?: number | null): boolean {
return concurrentLimit === 0
}
function formatWalletAmount(value: number | null, nullLabel = '无限制'): string { function formatWalletAmount(value: number | null, nullLabel = '无限制'): string {
if (value == null) { if (value == null) {
return nullLabel return nullLabel
@@ -1110,6 +1147,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
name: data.name || undefined, name: data.name || undefined,
unlimited_balance: Boolean(data.unlimited_balance), unlimited_balance: Boolean(data.unlimited_balance),
rate_limit: data.rate_limit ?? null, // undefined = 跟随系统默认,显式传 null rate_limit: data.rate_limit ?? null, // undefined = 跟随系统默认,显式传 null
concurrent_limit: data.concurrent_limit ?? null,
expires_at: serializeExpiryDate(data.expires_at), expires_at: serializeExpiryDate(data.expires_at),
auto_delete_on_expiry: data.auto_delete_on_expiry, auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL // 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
@@ -1139,6 +1177,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
name: data.name || undefined, name: data.name || undefined,
initial_balance_usd: isUnlimited ? null : (data.initial_balance_usd as number), initial_balance_usd: isUnlimited ? null : (data.initial_balance_usd as number),
rate_limit: data.rate_limit ?? null, // undefined = 跟随系统默认,显式传 null rate_limit: data.rate_limit ?? null, // undefined = 跟随系统默认,显式传 null
concurrent_limit: data.concurrent_limit ?? null,
expires_at: serializeExpiryDate(data.expires_at), expires_at: serializeExpiryDate(data.expires_at),
auto_delete_on_expiry: data.auto_delete_on_expiry, auto_delete_on_expiry: data.auto_delete_on_expiry,
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL // 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL

View File

@@ -679,6 +679,12 @@
> >
{{ formatRateLimitSimple(apiKey.rate_limit) }} {{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge> </Badge>
<Badge
variant="secondary"
class="text-xs"
>
{{ formatConcurrentLimitSimple(apiKey.concurrent_limit) }}
</Badge>
</div> </div>
<div class="flex items-center gap-1 mt-0.5"> <div class="flex items-center gap-1 mt-0.5">
<code class="text-xs font-mono text-muted-foreground"> <code class="text-xs font-mono text-muted-foreground">
@@ -795,7 +801,7 @@
{{ editingUserApiKey ? '编辑 API Key' : '创建 API Key' }} {{ editingUserApiKey ? '编辑 API Key' : '创建 API Key' }}
</h3> </h3>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{{ editingUserApiKey ? '更新用户 API Key 的名称速率限制' : '为用户创建新的 API Key' }} {{ editingUserApiKey ? '更新用户 API Key 的名称速率限制和并发限制' : '为用户创建新的 API Key' }}
</p> </p>
</div> </div>
</div> </div>
@@ -834,6 +840,25 @@
留空表示不限制 留空表示不限制
</p> </p>
</div> </div>
<div class="space-y-2">
<Label
for="admin-user-key-concurrent-limit"
class="text-sm font-medium"
>并发限制</Label>
<Input
id="admin-user-key-concurrent-limit"
:model-value="userApiKeyForm.concurrent_limit ?? ''"
type="number"
min="0"
max="10000"
class="h-10"
placeholder="0 = 不限并发"
@update:model-value="(v) => userApiKeyForm.concurrent_limit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<p class="text-xs text-muted-foreground">
{{ editingUserApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
</p>
</div>
</div> </div>
<template #footer> <template #footer>
@@ -1099,6 +1124,7 @@ const editingUserApiKey = ref<ApiKey | null>(null)
const userApiKeyForm = ref({ const userApiKeyForm = ref({
name: '', name: '',
rate_limit: undefined as number | undefined, rate_limit: undefined as number | undefined,
concurrent_limit: undefined as number | undefined,
}) })
// 用户统计 // 用户统计
@@ -1253,6 +1279,13 @@ function formatCurrencyValue(value: number | null, nullLabel = '-'): string {
return `$${value.toFixed(2)}` return `$${value.toFixed(2)}`
} }
function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
if (concurrentLimit == null || concurrentLimit === 0) {
return '不限并发'
}
return `${concurrentLimit} 并发`
}
function isNegativeWalletValue(value: number | null): boolean { function isNegativeWalletValue(value: number | null): boolean {
return typeof value === 'number' && value < 0 return typeof value === 'number' && value < 0
} }
@@ -1387,6 +1420,7 @@ function openCreateUserApiKeyDialog() {
userApiKeyForm.value = { userApiKeyForm.value = {
name: `Key-${new Date().toISOString().split('T')[0]}`, name: `Key-${new Date().toISOString().split('T')[0]}`,
rate_limit: undefined, rate_limit: undefined,
concurrent_limit: undefined,
} }
editingUserApiKey.value = null editingUserApiKey.value = null
showUserApiKeyFormDialog.value = true showUserApiKeyFormDialog.value = true
@@ -1397,6 +1431,7 @@ function openEditUserApiKeyDialog(apiKey: ApiKey) {
userApiKeyForm.value = { userApiKeyForm.value = {
name: apiKey.name || '', name: apiKey.name || '',
rate_limit: apiKey.rate_limit ?? undefined, rate_limit: apiKey.rate_limit ?? undefined,
concurrent_limit: apiKey.concurrent_limit ?? undefined,
} }
showUserApiKeyFormDialog.value = true showUserApiKeyFormDialog.value = true
} }
@@ -1407,6 +1442,7 @@ function closeUserApiKeyFormDialog() {
userApiKeyForm.value = { userApiKeyForm.value = {
name: '', name: '',
rate_limit: undefined, rate_limit: undefined,
concurrent_limit: undefined,
} }
} }
@@ -1423,12 +1459,14 @@ async function submitUserApiKeyForm() {
await usersStore.updateApiKey(selectedUser.value.id, editingUserApiKey.value.id, { await usersStore.updateApiKey(selectedUser.value.id, editingUserApiKey.value.id, {
name: userApiKeyForm.value.name, name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0, rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
}) })
success('API Key已更新') success('API Key已更新')
} else { } else {
const response = await usersStore.createApiKey(selectedUser.value.id, { const response = await usersStore.createApiKey(selectedUser.value.id, {
name: userApiKeyForm.value.name, name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0, rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
}) })
newApiKey.value = response.key || '' newApiKey.value = response.key || ''
showNewApiKeyDialog.value = true showNewApiKeyDialog.value = true

View File

@@ -174,6 +174,12 @@
> >
{{ formatRateLimitSimple(apiKey.rate_limit) }} {{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge> </Badge>
<Badge
variant="secondary"
class="h-5 px-2 py-0 text-[10px] font-medium"
>
{{ formatConcurrentLimitSimple(apiKey.concurrent_limit) }}
</Badge>
</div> </div>
</TableCell> </TableCell>
@@ -259,6 +265,12 @@
> >
{{ formatRateLimitSimple(apiKey.rate_limit) }} {{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge> </Badge>
<Badge
variant="secondary"
class="text-[10px] px-1.5 py-0"
>
{{ formatConcurrentLimitSimple(apiKey.concurrent_limit) }}
</Badge>
</div> </div>
<div class="flex items-center gap-0.5 flex-shrink-0"> <div class="flex items-center gap-0.5 flex-shrink-0">
<Button <Button
@@ -355,7 +367,7 @@
{{ editingApiKey ? '编辑 API 密钥' : '创建 API 密钥' }} {{ editingApiKey ? '编辑 API 密钥' : '创建 API 密钥' }}
</h3> </h3>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{{ editingApiKey ? '更新密钥名称速率限制' : '创建一个新的密钥用于访问 API 服务' }} {{ editingApiKey ? '更新密钥名称速率限制和并发限制' : '创建一个新的密钥用于访问 API 服务' }}
</p> </p>
</div> </div>
</div> </div>
@@ -400,6 +412,26 @@
留空不限 留空不限
</p> </p>
</div> </div>
<div class="space-y-2">
<Label
for="key-concurrent-limit"
class="text-sm font-semibold"
>并发限制</Label>
<Input
id="key-concurrent-limit"
:model-value="newKeyConcurrentLimit ?? ''"
type="number"
min="0"
max="10000"
placeholder="0 = 不限并发"
class="h-11 border-border/60"
@update:model-value="(v) => newKeyConcurrentLimit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<p class="text-xs text-muted-foreground">
{{ editingApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
</p>
</div>
</div> </div>
<template #footer> <template #footer>
@@ -542,6 +574,7 @@ const showDeleteDialog = ref(false)
const newKeyName = ref('') const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined) const newKeyRateLimit = ref<number | undefined>(undefined)
const newKeyConcurrentLimit = ref<number | undefined>(undefined)
const newKeyValue = ref('') const newKeyValue = ref('')
const keyToDelete = ref<ApiKey | null>(null) const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null) const editingApiKey = ref<ApiKey | null>(null)
@@ -573,6 +606,7 @@ function openEditApiKeyDialog(apiKey: ApiKey) {
editingApiKey.value = apiKey editingApiKey.value = apiKey
newKeyName.value = apiKey.name || '' newKeyName.value = apiKey.name || ''
newKeyRateLimit.value = apiKey.rate_limit ?? undefined newKeyRateLimit.value = apiKey.rate_limit ?? undefined
newKeyConcurrentLimit.value = apiKey.concurrent_limit ?? undefined
showCreateDialog.value = true showCreateDialog.value = true
} }
@@ -580,6 +614,7 @@ function openCreateApiKeyDialog() {
editingApiKey.value = null editingApiKey.value = null
newKeyName.value = '' newKeyName.value = ''
newKeyRateLimit.value = undefined newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
showCreateDialog.value = true showCreateDialog.value = true
} }
@@ -588,6 +623,7 @@ function closeApiKeyDialog() {
editingApiKey.value = null editingApiKey.value = null
newKeyName.value = '' newKeyName.value = ''
newKeyRateLimit.value = undefined newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
} }
async function saveApiKey() { async function saveApiKey() {
@@ -602,12 +638,14 @@ async function saveApiKey() {
await meApi.updateApiKey(editingApiKey.value.id, { await meApi.updateApiKey(editingApiKey.value.id, {
name: newKeyName.value, name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0, rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
}) })
success('API 密钥更新成功') success('API 密钥更新成功')
} else { } else {
const newKey = await meApi.createApiKey({ const newKey = await meApi.createApiKey({
name: newKeyName.value, name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0, rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
}) })
newKeyValue.value = newKey.key || '' newKeyValue.value = newKey.key || ''
showKeyDialog.value = true showKeyDialog.value = true
@@ -711,6 +749,13 @@ function formatNumber(num: number | undefined | null): string {
return num.toLocaleString('zh-CN') return num.toLocaleString('zh-CN')
} }
function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
if (concurrentLimit == null || concurrentLimit === 0) {
return '不限并发'
}
return `${concurrentLimit} 并发`
}
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', { return date.toLocaleDateString('zh-CN', {