mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(pool): 引入 pro_first 调度预设、Pool 候选持久化跳过与诊断信息优化
- 新增 pro_first 调度预设(Pro 优先),更新 plus_first 仅针对 Plus 计划,移除 free_team_first - Pool 内部候选(pool_key_index 不为空)跳过 DB 持久化(available/skipped/unused 均适用) - LRU 排序新增 catalog_lru_score 回退:runtime 无记录时使用 last_used_at_unix_secs - 执行路径 miss 诊断消息细化为中文,按 reason 分类输出可读说明 - build_local_request_candidate_status_record 补充 extra_data 和 created_at_unix_ms 字段 - OpenAI CLI 计划构建流程补充候选评估进度跟踪与 terminal reason 设置 - 前端 PoolSchedulingDialog 增加 pro_first 预设展示,修复 LRU 默认预设检测逻辑
This commit is contained in:
@@ -9,10 +9,10 @@ const POOL_ALLOWED_SCHEDULING_PRESETS: &[&str] = &[
|
||||
"load_balance",
|
||||
"single_account",
|
||||
"priority_first",
|
||||
"free_team_first",
|
||||
"free_first",
|
||||
"team_first",
|
||||
"plus_first",
|
||||
"pro_first",
|
||||
"health_first",
|
||||
"latency_first",
|
||||
"cost_first",
|
||||
@@ -28,12 +28,12 @@ fn json_u64(value: &Value) -> Option<u64> {
|
||||
|
||||
fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option<String> {
|
||||
match preset {
|
||||
"free_team_first" | "free_first" | "team_first" | "plus_first" => {
|
||||
"free_first" | "team_first" | "plus_first" | "pro_first" => {
|
||||
let default_mode = match preset {
|
||||
"free_team_first" => "both",
|
||||
"free_first" => "free_only",
|
||||
"team_first" => "team_only",
|
||||
"plus_first" => "plus_only",
|
||||
"pro_first" => "pro_only",
|
||||
_ => unreachable!("preset covered by outer match"),
|
||||
};
|
||||
let normalized = raw_mode
|
||||
@@ -42,12 +42,10 @@ fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option<
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.filter(|value| match preset {
|
||||
"free_team_first" => {
|
||||
matches!(value.as_str(), "both" | "free_only" | "team_only")
|
||||
}
|
||||
"free_first" => value == "free_only",
|
||||
"team_first" => value == "team_only",
|
||||
"plus_first" => value == "plus_only",
|
||||
"pro_first" => value == "pro_only",
|
||||
_ => false,
|
||||
})
|
||||
.unwrap_or_else(|| default_mode.to_string());
|
||||
@@ -426,14 +424,15 @@ mod tests {
|
||||
"pool_advanced": {
|
||||
"scheduling_presets": [
|
||||
{"preset": "cache_affinity", "enabled": false},
|
||||
{"preset": "plus_first", "enabled": true, "mode": "plus_only"}
|
||||
{"preset": "plus_first", "enabled": true, "mode": "plus_only"},
|
||||
{"preset": "pro_first", "enabled": true, "mode": "pro_only"}
|
||||
]
|
||||
}
|
||||
})))
|
||||
.expect("pool config should parse");
|
||||
|
||||
assert!(!config.lru_enabled);
|
||||
assert_eq!(config.scheduling_presets.len(), 2);
|
||||
assert_eq!(config.scheduling_presets.len(), 3);
|
||||
assert_eq!(config.scheduling_presets[0].preset, "cache_affinity");
|
||||
assert!(!config.scheduling_presets[0].enabled);
|
||||
assert_eq!(config.scheduling_presets[1].preset, "plus_first");
|
||||
@@ -441,17 +440,22 @@ mod tests {
|
||||
config.scheduling_presets[1].mode.as_deref(),
|
||||
Some("plus_only")
|
||||
);
|
||||
assert_eq!(config.scheduling_presets[2].preset, "pro_first");
|
||||
assert_eq!(
|
||||
config.scheduling_presets[2].mode.as_deref(),
|
||||
Some("pro_only")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_string_style_scheduling_presets_like_python() {
|
||||
fn parses_legacy_string_style_scheduling_presets() {
|
||||
let config = admin_provider_pool_config_from_config_value(Some(&json!({
|
||||
"pool_advanced": {
|
||||
"lru_enabled": false,
|
||||
"scheduling_presets": [
|
||||
"free_team_first",
|
||||
"free_first",
|
||||
"recent_refresh",
|
||||
"free_team_first"
|
||||
"free_first"
|
||||
]
|
||||
}
|
||||
})))
|
||||
@@ -461,24 +465,24 @@ mod tests {
|
||||
assert_eq!(config.scheduling_presets.len(), 3);
|
||||
assert_eq!(config.scheduling_presets[0].preset, "lru");
|
||||
assert!(!config.scheduling_presets[0].enabled);
|
||||
assert_eq!(config.scheduling_presets[1].preset, "free_team_first");
|
||||
assert_eq!(config.scheduling_presets[1].preset, "free_first");
|
||||
assert_eq!(config.scheduling_presets[2].preset, "recent_refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_free_team_first_mode_defaults_to_both() {
|
||||
fn retired_free_team_first_preset_is_rejected() {
|
||||
let config = admin_provider_pool_config_from_config_value(Some(&json!({
|
||||
"pool_advanced": {
|
||||
"scheduling_presets": [
|
||||
{"preset": "free_team_first", "enabled": true, "mode": "invalid_mode"}
|
||||
{"preset": "free_team_first", "enabled": true, "mode": "team_only"}
|
||||
]
|
||||
}
|
||||
})))
|
||||
.expect("pool config should parse");
|
||||
|
||||
assert_eq!(config.scheduling_presets.len(), 1);
|
||||
assert_eq!(config.scheduling_presets[0].preset, "free_team_first");
|
||||
assert_eq!(config.scheduling_presets[0].mode.as_deref(), Some("both"));
|
||||
assert_eq!(config.scheduling_presets[0].preset, "lru");
|
||||
assert_eq!(config.scheduling_presets[0].mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,19 +62,19 @@ use std::{collections::BTreeMap, time::Instant};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const OPENAI_CHAT_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI chat execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Chat Completions 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const OPENAI_RESPONSES_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI responses execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Responses 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const OPENAI_COMPACT_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI compact execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Responses Compact 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const OPENAI_VIDEO_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"OpenAI video execution runtime miss did not match a Rust execution path";
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const CLAUDE_MESSAGES_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"Claude messages execution runtime miss did not match a Rust execution path";
|
||||
"当前 Claude Messages 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const GEMINI_PUBLIC_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"Gemini public execution runtime miss did not match a Rust execution path";
|
||||
"当前 Gemini Public 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const GEMINI_FILES_LOCAL_EXECUTION_RUNTIME_MISS_DETAIL: &str =
|
||||
"Gemini files execution runtime miss did not match a Rust execution path";
|
||||
"当前 Gemini Files 请求无法在本地执行:没有匹配到可用的执行路径";
|
||||
const LOCAL_ROUTE_NOT_FOUND_DETAIL: &str = "Route not found";
|
||||
const LOCAL_PROXY_PASSTHROUGH_REMOVED_DETAIL: &str =
|
||||
"Route matched a removed compatibility passthrough; implement it in Rust or retire the route";
|
||||
@@ -1258,9 +1258,7 @@ pub(crate) async fn proxy_request(
|
||||
auth_api_key_concurrency_limited,
|
||||
stream_request,
|
||||
)
|
||||
.unwrap_or_else(|| {
|
||||
"AI public execution runtime miss did not match a Rust execution path".to_string()
|
||||
});
|
||||
.unwrap_or_else(|| "当前 AI 请求无法在本地执行:没有匹配到可用的执行路径".to_string());
|
||||
let local_execution_failure_path = if auth_api_key_concurrency_limited {
|
||||
EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED
|
||||
} else {
|
||||
@@ -1424,34 +1422,237 @@ fn local_execution_runtime_miss_detail(
|
||||
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_diagnostic_detail(decision, diagnostic, stream_request)
|
||||
{
|
||||
return Some(detail);
|
||||
}
|
||||
|
||||
local_execution_runtime_miss_route_detail(decision).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_model_detail(
|
||||
fn local_execution_runtime_miss_diagnostic_detail(
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
|
||||
stream_request: bool,
|
||||
) -> Option<String> {
|
||||
let diagnostic = diagnostic?;
|
||||
if !matches!(
|
||||
diagnostic.reason.as_str(),
|
||||
"candidate_list_empty" | "all_candidates_skipped"
|
||||
) {
|
||||
return None;
|
||||
let route_label = local_execution_runtime_miss_route_label(decision);
|
||||
let request_mode = local_execution_runtime_miss_request_mode(stream_request);
|
||||
|
||||
match diagnostic.reason.as_str() {
|
||||
"candidate_list_empty" => {
|
||||
return Some(local_execution_runtime_miss_candidate_list_empty_detail(
|
||||
diagnostic,
|
||||
request_mode,
|
||||
));
|
||||
}
|
||||
"all_candidates_skipped" => {
|
||||
return Some(local_execution_runtime_miss_all_candidates_skipped_detail(
|
||||
diagnostic,
|
||||
request_mode,
|
||||
));
|
||||
}
|
||||
"missing_auth_context" => {
|
||||
return Some(format!(
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商({route_label},原因代码: missing_auth_context)"
|
||||
));
|
||||
}
|
||||
"missing_requested_model" => {
|
||||
return Some(format!(
|
||||
"请求缺少 model 字段,无法选择上游提供商({route_label},原因代码: missing_requested_model)"
|
||||
));
|
||||
}
|
||||
"auth_snapshot_missing" => {
|
||||
return Some(format!(
|
||||
"当前 API Key 的本地执行配置不存在或已过期,无法选择上游提供商({route_label},原因代码: auth_snapshot_missing)"
|
||||
));
|
||||
}
|
||||
"auth_snapshot_read_failed" => {
|
||||
return Some(format!(
|
||||
"读取 API Key 的本地执行配置失败,无法选择上游提供商({route_label},原因代码: auth_snapshot_read_failed)"
|
||||
));
|
||||
}
|
||||
"decision_input_unavailable" => {
|
||||
return Some(format!(
|
||||
"请求缺少本地执行所需的认证、模型或配置上下文,无法选择上游提供商({route_label},原因代码: decision_input_unavailable)"
|
||||
));
|
||||
}
|
||||
"execution_runtime_candidates_exhausted" => {
|
||||
return Some(format!(
|
||||
"已尝试所有本地执行候选提供商,但没有任何候选成功完成请求({route_label},原因代码: execution_runtime_candidates_exhausted)"
|
||||
));
|
||||
}
|
||||
"candidate_evaluation_incomplete" => {
|
||||
return Some(format!(
|
||||
"本地执行候选评估未完成,暂时无法为本次{request_mode}请求选择上游提供商({route_label},原因代码: candidate_evaluation_incomplete)"
|
||||
));
|
||||
}
|
||||
"no_local_sync_plans" | "no_local_stream_plans" => {
|
||||
return Some(format!(
|
||||
"找到了候选提供商,但无法为本次{request_mode}请求构建本地执行计划。请检查端点路径、认证方式、Header/Body 规则和格式转换配置({route_label},原因代码: {})",
|
||||
diagnostic.reason
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let requested_model = diagnostic
|
||||
let reason = diagnostic.reason.trim();
|
||||
if reason.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"当前请求无法在本地执行:{route_label} 的执行路径未就绪(原因代码: {reason})"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_candidate_list_empty_detail(
|
||||
diagnostic: &LocalExecutionRuntimeMissDiagnostic,
|
||||
request_mode: &str,
|
||||
) -> String {
|
||||
if let Some(requested_model) = diagnostic_requested_model(diagnostic) {
|
||||
return format!(
|
||||
"没有可用提供商支持模型 {requested_model} 的{request_mode}请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"没有可用提供商支持本次{request_mode}请求。请检查模型字段、模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
)
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_all_candidates_skipped_detail(
|
||||
diagnostic: &LocalExecutionRuntimeMissDiagnostic,
|
||||
request_mode: &str,
|
||||
) -> String {
|
||||
let candidate_count = diagnostic.candidate_count.unwrap_or(0);
|
||||
let skipped_count = diagnostic
|
||||
.skipped_candidate_count
|
||||
.unwrap_or(candidate_count);
|
||||
let skipped_summary =
|
||||
local_execution_runtime_miss_skip_reasons_summary(&diagnostic.skip_reasons);
|
||||
let requested_model = diagnostic_requested_model(diagnostic);
|
||||
|
||||
match (candidate_count, skipped_summary, requested_model) {
|
||||
(count, Some(summary), Some(model)) if count > 0 => format!(
|
||||
"找到 {count} 个支持模型 {model} 的候选提供商,但本次{request_mode}请求全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(count, Some(summary), None) if count > 0 => format!(
|
||||
"找到 {count} 个候选提供商,但本次{request_mode}请求全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(_, Some(summary), Some(model)) => format!(
|
||||
"支持模型 {model} 的候选提供商全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(_, Some(summary), None) => format!(
|
||||
"候选提供商全部不可用:{summary}(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(count, None, Some(model)) if count > 0 => format!(
|
||||
"找到 {count} 个支持模型 {model} 的候选提供商,但都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(count, None, None) if count > 0 => format!(
|
||||
"找到 {count} 个候选提供商,但都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
(_, None, Some(model)) if skipped_count > 0 => format!(
|
||||
"支持模型 {model} 的 {skipped_count} 个候选提供商都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
_ => format!(
|
||||
"候选提供商都不满足本次{request_mode}请求要求(原因代码: all_candidates_skipped)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_requested_model(diagnostic: &LocalExecutionRuntimeMissDiagnostic) -> Option<&str> {
|
||||
diagnostic
|
||||
.requested_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let request_mode = if stream_request { "流式" } else { "同步" };
|
||||
Some(format!(
|
||||
"没有可用的提供商支持模型 {requested_model} 的{request_mode}请求"
|
||||
))
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_skip_reasons_summary(
|
||||
skip_reasons: &BTreeMap<String, usize>,
|
||||
) -> Option<String> {
|
||||
if skip_reasons.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
skip_reasons
|
||||
.iter()
|
||||
.map(|(reason, count)| {
|
||||
format!(
|
||||
"{} {} 次",
|
||||
local_execution_runtime_miss_skip_reason_label(reason),
|
||||
count
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_skip_reason_label(reason: &str) -> &str {
|
||||
match reason {
|
||||
"api_key_concurrency_limit_reached" => "API Key 并发已达上限",
|
||||
"auth_snapshot_missing" => "API Key 本地执行配置缺失",
|
||||
"endpoint_api_format_changed" => "端点 API 格式已变更",
|
||||
"endpoint_inactive" => "端点未启用",
|
||||
"key_api_format_disabled" => "API Key 未启用该 API 格式",
|
||||
"key_inactive" => "API Key 未启用",
|
||||
"key_model_disabled" => "API Key 未允许该模型",
|
||||
"mapped_model_missing" => "模型映射缺失",
|
||||
"provider_inactive" => "提供商未启用",
|
||||
"provider_request_body_missing" => "无法构建上游请求体",
|
||||
"transport_api_format_mismatch" => "传输层 API 格式不匹配",
|
||||
"transport_api_format_unsupported" => "传输层不支持该 API 格式",
|
||||
"transport_auth_unavailable" => "上游认证信息不可用",
|
||||
"transport_body_rules_unsupported" => "Body 规则不支持本地执行",
|
||||
"transport_custom_path_unsupported" => "自定义路径不支持本地执行",
|
||||
"transport_header_rules_unsupported" => "Header 规则不支持本地执行",
|
||||
"transport_header_rules_apply_failed" => "Header 规则应用失败",
|
||||
"transport_oauth_resolution_unsupported" => "OAuth 认证解析不支持本地执行",
|
||||
"transport_provider_type_unsupported" => "提供商类型不支持本地执行",
|
||||
"transport_proxy_or_tls_unsupported" => "代理或 TLS 配置不支持本地执行",
|
||||
"transport_proxy_unsupported" => "代理配置不支持本地执行",
|
||||
"transport_snapshot_missing" => "提供商传输配置缺失",
|
||||
"transport_tls_profile_unsupported" => "TLS 指纹配置不支持本地执行",
|
||||
"transport_unsupported" => "传输配置不支持本地执行",
|
||||
"upstream_url_missing" => "无法构建上游请求地址",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_request_mode(stream_request: bool) -> &'static str {
|
||||
if stream_request {
|
||||
"流式"
|
||||
} else {
|
||||
"同步"
|
||||
}
|
||||
}
|
||||
|
||||
fn local_execution_runtime_miss_route_label(
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
) -> &'static str {
|
||||
let Some(decision) = decision else {
|
||||
return "AI 请求";
|
||||
};
|
||||
match decision.public_path.as_str() {
|
||||
"/v1/chat/completions" => "OpenAI Chat Completions",
|
||||
"/v1/responses" => "OpenAI Responses",
|
||||
"/v1/responses/compact" => "OpenAI Responses Compact",
|
||||
"/v1/messages" => "Claude Messages",
|
||||
path if path.starts_with("/v1/videos") => "OpenAI Video",
|
||||
path if path.starts_with("/upload/v1beta/files") || path.starts_with("/v1beta/files") => {
|
||||
"Gemini Files"
|
||||
}
|
||||
path if decision.route_family.as_deref() == Some("gemini")
|
||||
&& (path.starts_with("/v1beta/models/") || path.starts_with("/v1/models/")) =>
|
||||
{
|
||||
"Gemini Public"
|
||||
}
|
||||
_ => "AI 请求",
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_is_auth_api_key_concurrency_limited(
|
||||
@@ -1526,12 +1727,14 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
detail.as_deref(),
|
||||
Some("没有可用的提供商支持模型 gpt-5.4 的流式请求")
|
||||
Some(
|
||||
"没有可用提供商支持模型 gpt-5.4 的流式请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_miss_detail_falls_back_to_route_default_when_reason_is_not_model_unavailable() {
|
||||
fn runtime_miss_detail_returns_auth_context_message_when_auth_context_is_missing() {
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
"/v1/messages",
|
||||
Some("ai_public".to_string()),
|
||||
@@ -1550,7 +1753,9 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
detail.as_deref(),
|
||||
Some("Claude messages execution runtime miss did not match a Rust execution path")
|
||||
Some(
|
||||
"请求缺少有效的用户或 API Key 认证上下文,无法选择上游提供商(Claude Messages,原因代码: missing_auth_context)"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user