feat(rust): 支持 image 同步转流式 SSE、free_team_first 调度预设及账户错误自动重试

- openai:image 同步响应桥接为流式 SSE(image_generation.completed / image_edit.completed 事件)
- image 请求解析与前置校验移除模型白名单,支持任意自定义模型名
- 调度器新增 free_team_first 预设(mode: both / free_only / team_only)
- pool config 解析重构:新增 POOL_ALLOWED_SCHEDULING_PRESETS 白名单,规范化 mode 字段
- 错误分类器新增账户/账单错误模式集,升级为 RetryUpstreamFailure 而非 StopSemanticClientError
- 修复 stream execution 中上游 headers 与输出 headers 混用导致 content-type 判断错误的问题
- codex image 工具始终写入 action 字段(generate/edit),仅 generate 操作填充默认 size/quality
- 前端:pool 节点组只展示实际执行过的候选节点,全部 skipped 时折叠为最后一个节点
- Redis 测试就绪检测从 TCP 连接改为 PING/PONG 协议验证
This commit is contained in:
fawney19
2026-04-24 10:05:55 +08:00
parent a9d10163af
commit 5d710d9d10
16 changed files with 1388 additions and 204 deletions

View File

@@ -3,48 +3,67 @@ use crate::handlers::admin::provider::shared::support::{
};
use serde_json::{Map, Value};
const POOL_ALLOWED_SCHEDULING_PRESETS: &[&str] = &[
"lru",
"cache_affinity",
"load_balance",
"single_account",
"priority_first",
"free_team_first",
"free_first",
"team_first",
"plus_first",
"health_first",
"latency_first",
"cost_first",
"quota_balanced",
"recent_refresh",
];
fn json_u64(value: &Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_i64().and_then(|raw| u64::try_from(raw).ok()))
}
fn parse_pool_scheduling_presets(
raw_pool_advanced: &Map<String, Value>,
) -> Vec<AdminProviderPoolSchedulingPreset> {
let Some(presets) = raw_pool_advanced
.get("scheduling_presets")
.and_then(Value::as_array)
else {
return raw_pool_advanced
.get("lru_enabled")
.and_then(Value::as_bool)
.filter(|enabled| *enabled)
.map(|_| {
vec![AdminProviderPoolSchedulingPreset {
preset: "lru".to_string(),
enabled: true,
mode: None,
}]
})
.unwrap_or_default();
};
let mut normalized = Vec::new();
for item in presets {
if let Some(preset) = item
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
normalized.push(AdminProviderPoolSchedulingPreset {
preset: preset.to_ascii_lowercase(),
enabled: true,
mode: None,
});
continue;
fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option<String> {
match preset {
"free_team_first" | "free_first" | "team_first" | "plus_first" => {
let default_mode = match preset {
"free_team_first" => "both",
"free_first" => "free_only",
"team_first" => "team_only",
"plus_first" => "plus_only",
_ => unreachable!("preset covered by outer match"),
};
let normalized = raw_mode
.and_then(Value::as_str)
.map(str::trim)
.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",
_ => false,
})
.unwrap_or_else(|| default_mode.to_string());
Some(normalized)
}
_ => None,
}
}
fn parse_object_style_pool_scheduling_presets(
presets: &[Value],
) -> Vec<AdminProviderPoolSchedulingPreset> {
let mut normalized = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for item in presets {
let Some(object) = item.as_object() else {
continue;
};
@@ -53,40 +72,116 @@ fn parse_pool_scheduling_presets(
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
else {
continue;
};
if !POOL_ALLOWED_SCHEDULING_PRESETS.contains(&preset.as_str())
|| !seen.insert(preset.clone())
{
continue;
}
normalized.push(AdminProviderPoolSchedulingPreset {
preset: preset.to_ascii_lowercase(),
mode: normalize_pool_preset_mode(&preset, object.get("mode")),
preset,
enabled: object
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true),
mode: object
.get("mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase()),
});
}
if normalized.is_empty()
&& raw_pool_advanced
.get("lru_enabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
normalized.push(AdminProviderPoolSchedulingPreset {
if normalized.is_empty() {
vec![AdminProviderPoolSchedulingPreset {
preset: "lru".to_string(),
enabled: true,
mode: None,
}]
} else {
normalized
}
}
fn parse_legacy_string_style_pool_scheduling_presets(
raw_pool_advanced: &Map<String, Value>,
presets: &[Value],
) -> Vec<AdminProviderPoolSchedulingPreset> {
let lru_enabled = raw_pool_advanced
.get("lru_enabled")
.and_then(Value::as_bool)
.unwrap_or(true);
let mut normalized = vec![AdminProviderPoolSchedulingPreset {
preset: "lru".to_string(),
enabled: lru_enabled,
mode: None,
}];
let mut seen = std::collections::BTreeSet::from(["lru".to_string()]);
for item in presets {
let Some(preset) = item
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
else {
continue;
};
if preset == "lru"
|| !POOL_ALLOWED_SCHEDULING_PRESETS.contains(&preset.as_str())
|| !seen.insert(preset.clone())
{
continue;
}
normalized.push(AdminProviderPoolSchedulingPreset {
preset,
enabled: true,
mode: None,
});
}
normalized
}
fn parse_pool_scheduling_presets_from_legacy_fields(
raw_pool_advanced: &Map<String, Value>,
) -> Vec<AdminProviderPoolSchedulingPreset> {
if raw_pool_advanced
.get("lru_enabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
vec![AdminProviderPoolSchedulingPreset {
preset: "lru".to_string(),
enabled: true,
mode: None,
}]
} else {
vec![AdminProviderPoolSchedulingPreset {
preset: "cache_affinity".to_string(),
enabled: true,
mode: None,
}]
}
}
fn parse_pool_scheduling_presets(
raw_pool_advanced: &Map<String, Value>,
) -> Vec<AdminProviderPoolSchedulingPreset> {
match raw_pool_advanced
.get("scheduling_presets")
.and_then(Value::as_array)
{
Some(presets) if !presets.is_empty() => match presets.first() {
Some(Value::Object(_)) => parse_object_style_pool_scheduling_presets(presets),
Some(Value::String(_)) => {
parse_legacy_string_style_pool_scheduling_presets(raw_pool_advanced, presets)
}
_ => parse_pool_scheduling_presets_from_legacy_fields(raw_pool_advanced),
},
_ => parse_pool_scheduling_presets_from_legacy_fields(raw_pool_advanced),
}
}
fn parse_pool_unschedulable_rules(
raw_pool_advanced: &Map<String, Value>,
) -> Vec<AdminProviderPoolUnschedulableRule> {
@@ -115,16 +210,8 @@ fn parse_pool_unschedulable_rules(
}
fn admin_provider_pool_lru_enabled(
raw_pool_advanced: &Map<String, Value>,
scheduling_presets: &[AdminProviderPoolSchedulingPreset],
) -> bool {
if let Some(explicit) = raw_pool_advanced
.get("lru_enabled")
.and_then(Value::as_bool)
{
return explicit;
}
scheduling_presets
.iter()
.any(|item| item.enabled && item.preset.eq_ignore_ascii_case("lru"))
@@ -145,7 +232,11 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
let Some(pool_advanced) = raw_pool_advanced.as_object() else {
return Some(AdminProviderPoolConfig {
scheduling_presets: Vec::new(),
scheduling_presets: vec![AdminProviderPoolSchedulingPreset {
preset: "cache_affinity".to_string(),
enabled: true,
mode: None,
}],
unschedulable_rules: Vec::new(),
lru_enabled: false,
skip_exhausted_accounts: false,
@@ -167,7 +258,7 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
let unschedulable_rules = parse_pool_unschedulable_rules(pool_advanced);
Some(AdminProviderPoolConfig {
lru_enabled: admin_provider_pool_lru_enabled(pool_advanced, &scheduling_presets),
lru_enabled: admin_provider_pool_lru_enabled(&scheduling_presets),
scheduling_presets,
unschedulable_rules,
skip_exhausted_accounts: pool_advanced
@@ -316,6 +407,19 @@ mod tests {
assert_eq!(config.cost_limit_per_key_tokens, Some(4096));
}
#[test]
fn defaults_empty_pool_advanced_to_cache_affinity_preset() {
let config = admin_provider_pool_config_from_config_value(Some(&json!({
"pool_advanced": {}
})))
.expect("pool config should parse");
assert!(!config.lru_enabled);
assert_eq!(config.scheduling_presets.len(), 1);
assert_eq!(config.scheduling_presets[0].preset, "cache_affinity");
assert!(config.scheduling_presets[0].enabled);
}
#[test]
fn parses_object_style_scheduling_presets_with_modes() {
let config = admin_provider_pool_config_from_config_value(Some(&json!({
@@ -339,6 +443,44 @@ mod tests {
);
}
#[test]
fn parses_legacy_string_style_scheduling_presets_like_python() {
let config = admin_provider_pool_config_from_config_value(Some(&json!({
"pool_advanced": {
"lru_enabled": false,
"scheduling_presets": [
"free_team_first",
"recent_refresh",
"free_team_first"
]
}
})))
.expect("pool config should parse");
assert!(!config.lru_enabled);
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[2].preset, "recent_refresh");
}
#[test]
fn invalid_free_team_first_mode_defaults_to_both() {
let config = admin_provider_pool_config_from_config_value(Some(&json!({
"pool_advanced": {
"scheduling_presets": [
{"preset": "free_team_first", "enabled": true, "mode": "invalid_mode"}
]
}
})))
.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"));
}
#[test]
fn parses_unschedulable_rules_from_pool_advanced() {
let config = admin_provider_pool_config_from_config_value(Some(&json!({