Fix/api key concurrency runtime miss (#309)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

View File

@@ -1,5 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
use std::time::Instant;
use std::time::{Duration, Instant};
use aether_contracts::ExecutionPlan;
use aether_data_contracts::repository::candidates::{
@@ -73,6 +73,23 @@ impl LocalExecutionRuntimeMissContext {
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> {
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_username: auth_context.and_then(|value| value.username.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,
started_at: &Instant,
local_execution_runtime_miss_detail: &str,
execution_path: &str,
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
) {
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(
&mut data,
&mut request_metadata,
execution_path,
candidate_id.as_deref(),
candidate_index,
None,
@@ -264,6 +286,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
request_id: &str,
started_at: &Instant,
local_execution_runtime_miss_detail: &str,
execution_path: &str,
decision: Option<&GatewayControlDecision>,
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
context: &LocalExecutionRuntimeMissContext,
@@ -272,7 +295,8 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
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
.and_then(|value| value.client_api_format.clone())
.or_else(|| {
@@ -371,6 +395,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
apply_runtime_miss_usage_routing(
&mut data,
&mut request_metadata,
execution_path,
selected_candidate.map(|value| value.candidate.id.as_str()),
selected_candidate.map(|value| value.candidate.candidate_index),
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],
) -> Option<&RuntimeMissCandidateContext> {
candidates.iter().max_by_key(|candidate| {
(
candidate.candidate.retry_index,
candidate.candidate.candidate_index,
candidate
.candidate
.finished_at_unix_ms
.or(candidate.candidate.started_at_unix_ms)
.unwrap_or(candidate.candidate.created_at_unix_ms),
)
})
candidates
.iter()
.filter(|candidate| request_candidate_represents_provider_execution(&candidate.candidate))
.max_by_key(|candidate| {
(
candidate.candidate.retry_index,
candidate.candidate.candidate_index,
candidate
.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> {
@@ -582,6 +621,27 @@ async fn load_runtime_miss_candidate_contexts(
.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> {
ids.filter_map(|value| {
let trimmed = value.trim();
@@ -740,6 +800,7 @@ fn infer_endpoint_kind(api_format: &str) -> Option<&str> {
fn apply_runtime_miss_usage_routing(
data: &mut UsageEventData,
request_metadata: &mut Map<String, Value>,
execution_path: &str,
candidate_id: Option<&str>,
candidate_index: Option<u32>,
key_name: Option<&str>,
@@ -761,7 +822,7 @@ fn apply_runtime_miss_usage_routing(
data.execution_path = data
.execution_path
.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
.local_execution_runtime_miss_reason
.clone()
@@ -796,9 +857,15 @@ fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
#[cfg(test)]
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::state::LocalExecutionRuntimeMissDiagnostic;
use aether_data_contracts::repository::candidates::{
RequestCandidateStatus, StoredRequestCandidate,
};
use aether_usage_runtime::UsageEventData;
use serde_json::{json, Map, Value};
@@ -811,6 +878,7 @@ mod tests {
apply_runtime_miss_usage_routing(
&mut data,
&mut request_metadata,
EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS,
Some("cand-1"),
Some(2),
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());
}
}