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:
fawney19
2026-04-24 13:29:05 +08:00
parent f29649e3a8
commit f3c9835759
27 changed files with 950 additions and 153 deletions

View File

@@ -906,12 +906,22 @@ pub fn build_admin_pool_scheduling_presets_payload() -> Value {
{
"name": "plus_first",
"label": "Plus 优先",
"description": "优先消耗 Plus/Pro 账号(依赖 plan_type",
"description": "优先消耗 Plus 账号(依赖 plan_type",
"providers": ["codex", "kiro"],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 plan_typePlus/Pro 账号优先调度)",
"evidence_hint": "依据 plan_typePlus 账号优先调度)",
},
{
"name": "pro_first",
"label": "Pro 优先",
"description": "优先消耗 Pro 账号(依赖 plan_type",
"providers": ["codex", "kiro"],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 plan_typePro 账号优先调度)",
},
{
"name": "priority_first",

View File

@@ -10,6 +10,23 @@ use super::{
};
use crate::DataLayerError;
fn merge_extra_data(
existing: Option<serde_json::Value>,
overlay: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
match (existing, overlay) {
(
Some(serde_json::Value::Object(mut existing_object)),
Some(serde_json::Value::Object(overlay_object)),
) => {
existing_object.extend(overlay_object);
Some(serde_json::Value::Object(existing_object))
}
(_existing, Some(overlay)) => Some(overlay),
(existing, None) => existing,
}
}
#[derive(Debug, Default)]
pub struct InMemoryRequestCandidateRepository {
by_id: RwLock<BTreeMap<String, StoredRequestCandidate>>,
@@ -353,9 +370,10 @@ impl RequestCandidateWriteRepository for InMemoryRequestCandidateRepository {
concurrent_requests: candidate
.concurrent_requests
.or_else(|| existing.as_ref().and_then(|row| row.concurrent_requests)),
extra_data: candidate
.extra_data
.or_else(|| existing.as_ref().and_then(|row| row.extra_data.clone())),
extra_data: merge_extra_data(
existing.as_ref().and_then(|row| row.extra_data.clone()),
candidate.extra_data,
),
required_capabilities: candidate.required_capabilities.or_else(|| {
existing
.as_ref()
@@ -411,6 +429,7 @@ mod tests {
RequestCandidateReadRepository, RequestCandidateStatus, RequestCandidateWriteRepository,
StoredRequestCandidate, UpsertRequestCandidateRecord,
};
use serde_json::json;
fn sample_candidate(
id: &str,
@@ -540,7 +559,10 @@ mod tests {
error_message: None,
latency_ms: None,
concurrent_requests: None,
extra_data: None,
extra_data: Some(json!({
"execution_strategy": "local_cross_format",
"provider_name": "primary",
})),
required_capabilities: None,
created_at_unix_ms: Some(100),
started_at_unix_ms: None,
@@ -572,7 +594,10 @@ mod tests {
error_message: None,
latency_ms: Some(25),
concurrent_requests: Some(2),
extra_data: None,
extra_data: Some(json!({
"provider_api_format": "openai:cli",
"provider_name": "updated",
})),
required_capabilities: None,
created_at_unix_ms: None,
started_at_unix_ms: Some(101),
@@ -584,6 +609,27 @@ mod tests {
assert_eq!(updated.status, RequestCandidateStatus::Success);
assert_eq!(updated.status_code, Some(200));
assert_eq!(updated.latency_ms, Some(25));
assert_eq!(
updated
.extra_data
.as_ref()
.and_then(|value| value.get("execution_strategy")),
Some(&json!("local_cross_format"))
);
assert_eq!(
updated
.extra_data
.as_ref()
.and_then(|value| value.get("provider_api_format")),
Some(&json!("openai:cli"))
);
assert_eq!(
updated
.extra_data
.as_ref()
.and_then(|value| value.get("provider_name")),
Some(&json!("updated"))
);
assert_eq!(updated.started_at_unix_ms, Some(101));
}

View File

@@ -240,7 +240,14 @@ DO UPDATE SET
error_message = COALESCE(EXCLUDED.error_message, request_candidates.error_message),
latency_ms = COALESCE(EXCLUDED.latency_ms, request_candidates.latency_ms),
concurrent_requests = COALESCE(EXCLUDED.concurrent_requests, request_candidates.concurrent_requests),
extra_data = COALESCE(EXCLUDED.extra_data, request_candidates.extra_data),
extra_data = CASE
WHEN request_candidates.extra_data IS NULL THEN EXCLUDED.extra_data
WHEN EXCLUDED.extra_data IS NULL THEN request_candidates.extra_data
WHEN json_typeof(request_candidates.extra_data) = 'object'
AND json_typeof(EXCLUDED.extra_data) = 'object'
THEN (request_candidates.extra_data::jsonb || EXCLUDED.extra_data::jsonb)::json
ELSE EXCLUDED.extra_data
END,
required_capabilities = COALESCE(EXCLUDED.required_capabilities, request_candidates.required_capabilities),
started_at = COALESCE(EXCLUDED.started_at, request_candidates.started_at),
finished_at = COALESCE(EXCLUDED.finished_at, request_candidates.finished_at)

View File

@@ -302,6 +302,15 @@ pub fn build_local_request_candidate_status_record(
.filter(|value| !value.is_empty())?;
let metadata = parse_request_candidate_report_context(report_context)?;
let candidate_index = metadata.candidate_index?;
let extra_data = build_report_candidate_extra_data(
metadata.client_api_format.clone(),
metadata.provider_api_format.clone(),
metadata.upstream_url.clone(),
metadata.mapped_model.clone(),
metadata.key_name.clone(),
metadata.proxy.clone(),
);
let created_at_unix_ms = started_at_unix_ms.or(finished_at_unix_ms);
Some(UpsertRequestCandidateRecord {
id: candidate_id.to_string(),
@@ -323,9 +332,9 @@ pub fn build_local_request_candidate_status_record(
error_message,
latency_ms,
concurrent_requests: None,
extra_data: None,
extra_data,
required_capabilities: None,
created_at_unix_ms: None,
created_at_unix_ms,
started_at_unix_ms,
finished_at_unix_ms,
})
@@ -783,7 +792,12 @@ mod tests {
"candidate_index": 1,
"retry_index": 2,
"user_id": "user-1",
"api_key_id": "api-key-1"
"api_key_id": "api-key-1",
"client_api_format": "openai:chat",
"provider_api_format": "openai:cli",
"upstream_url": "https://example.com/v1/responses",
"mapped_model": "gpt-5-upstream",
"key_name": "primary"
})),
status_update: SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
@@ -802,6 +816,21 @@ mod tests {
assert_eq!(record.retry_index, 2);
assert_eq!(record.user_id.as_deref(), Some("user-1"));
assert_eq!(record.status, RequestCandidateStatus::Failed);
assert_eq!(record.created_at_unix_ms, Some(100));
assert_eq!(
record
.extra_data
.as_ref()
.and_then(|value| value.get("provider_api_format")),
Some(&json!("openai:cli"))
);
assert_eq!(
record
.extra_data
.as_ref()
.and_then(|value| value.get("mapped_model")),
Some(&json!("gpt-5-upstream"))
);
}
#[test]