mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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创建成功,请妥善保存完整密钥",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"]));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>>>,
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
21
frontend/src/features/api-keys/utils/userKeyPayload.ts
Normal file
21
frontend/src/features/api-keys/utils/userKeyPayload.ts
Normal 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 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
Reference in New Issue
Block a user