mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
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:
@@ -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!({
|
||||
|
||||
@@ -16,14 +16,11 @@ const CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL: &str = "请求体不能为空";
|
||||
const GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL: &str = "Video task not found";
|
||||
const AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL: &str = "Method not allowed";
|
||||
const AI_PUBLIC_UNAUTHORIZED_DETAIL: &str = "Unauthorized";
|
||||
const OPENAI_CHAT_IMAGE_MODEL_DETAIL: &str =
|
||||
"图片模型仅支持通过 /v1/images/generations、/v1/images/edits 或 /v1/images/variations 调用";
|
||||
const OPENAI_IMAGE_PROMPT_DETAIL: &str = "图片生成/编辑请求缺少 prompt";
|
||||
const OPENAI_IMAGE_EDIT_INPUT_DETAIL: &str = "图片编辑请求至少需要 1 张输入图片";
|
||||
const OPENAI_IMAGE_VARIATION_INPUT_DETAIL: &str = "图片变体请求需要 image 文件";
|
||||
const OPENAI_IMAGE_N_DETAIL: &str = "当前 Codex 图片反代仅支持 n=1";
|
||||
const OPENAI_IMAGE_STREAM_VARIATION_DETAIL: &str = "图片变体接口当前仅支持同步响应";
|
||||
const OPENAI_IMAGE_STREAM_MODEL_DETAIL: &str = "stream/partial_images 仅支持 GPT Image 系列模型";
|
||||
const OPENAI_IMAGE_PARTIAL_IMAGES_DETAIL: &str =
|
||||
"partial_images 仅支持 0-3,且必须配合 stream=true";
|
||||
const OPENAI_IMAGE_STYLE_DETAIL: &str = "当前 Codex 图片反代暂不支持 style 参数";
|
||||
@@ -132,14 +129,6 @@ fn maybe_build_local_openai_request_validation_response(
|
||||
if decision.route_kind.as_deref() == Some("chat")
|
||||
&& request_context.request_path == "/v1/chat/completions"
|
||||
{
|
||||
let payload = serde_json::from_slice::<Value>(request_body).ok()?;
|
||||
let model = payload.get("model").and_then(Value::as_str)?;
|
||||
if is_openai_image_model(model) {
|
||||
return Some(build_ai_public_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
OPENAI_CHAT_IMAGE_MODEL_DETAIL,
|
||||
));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -169,20 +158,6 @@ fn maybe_build_local_openai_request_validation_response(
|
||||
}
|
||||
};
|
||||
|
||||
if validation
|
||||
.model
|
||||
.as_deref()
|
||||
.is_some_and(|model| !image_model_supported_for_operation(operation, model))
|
||||
{
|
||||
return Some(build_ai_public_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
format!(
|
||||
"该接口不支持模型 {}",
|
||||
validation.model.as_deref().unwrap_or_default()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
match operation {
|
||||
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit
|
||||
if validation.prompt.is_none() =>
|
||||
@@ -237,12 +212,6 @@ fn maybe_build_local_openai_request_validation_response(
|
||||
OPENAI_IMAGE_STREAM_VARIATION_DETAIL,
|
||||
));
|
||||
}
|
||||
if !image_model_supports_streaming(validation.model.as_deref()) {
|
||||
return Some(build_ai_public_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
OPENAI_IMAGE_STREAM_MODEL_DETAIL,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if validation
|
||||
@@ -335,35 +304,6 @@ fn image_request_count(value: &Value) -> Option<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_openai_image_model(model: &str) -> bool {
|
||||
canonicalize_openai_image_model(model).is_some()
|
||||
}
|
||||
|
||||
fn canonicalize_openai_image_model(model: &str) -> Option<&'static str> {
|
||||
match model.trim().to_ascii_lowercase().as_str() {
|
||||
"gpt-image-1" => Some("gpt-image-1"),
|
||||
"gpt-image-1.5" => Some("gpt-image-1.5"),
|
||||
"gpt-image-1-mini" => Some("gpt-image-1-mini"),
|
||||
"gpt-image-2" => Some("gpt-image-2"),
|
||||
"chatgpt-image-latest" => Some("chatgpt-image-latest"),
|
||||
"dall-e-2" => Some("dall-e-2"),
|
||||
"dall-e-3" => Some("dall-e-3"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn image_model_supported_for_operation(operation: OpenAiImageOperation, model: &str) -> bool {
|
||||
match operation {
|
||||
OpenAiImageOperation::Generate => true,
|
||||
OpenAiImageOperation::Edit => !matches!(model, "dall-e-3"),
|
||||
OpenAiImageOperation::Variation => model == "dall-e-2",
|
||||
}
|
||||
}
|
||||
|
||||
fn image_model_supports_streaming(model: Option<&str>) -> bool {
|
||||
!matches!(model, Some("dall-e-2" | "dall-e-3"))
|
||||
}
|
||||
|
||||
fn parse_openai_image_validation_input(
|
||||
operation: OpenAiImageOperation,
|
||||
content_type: Option<&str>,
|
||||
@@ -398,8 +338,7 @@ fn parse_openai_image_validation_input_from_json(
|
||||
Ok(OpenAiImageValidationInput {
|
||||
model: normalize_openai_image_model_for_operation(
|
||||
object.get("model").and_then(Value::as_str),
|
||||
)
|
||||
.ok_or(OPENAI_IMAGE_INVALID_JSON_DETAIL)?,
|
||||
),
|
||||
prompt: object
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
@@ -480,8 +419,7 @@ fn parse_openai_image_validation_input_from_multipart(
|
||||
.map(|field| String::from_utf8_lossy(&field.data).trim().to_string());
|
||||
|
||||
Ok(OpenAiImageValidationInput {
|
||||
model: normalize_openai_image_model_for_operation(model.as_deref())
|
||||
.ok_or(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL)?,
|
||||
model: normalize_openai_image_model_for_operation(model.as_deref()),
|
||||
prompt: multipart_text_field(&fields, "prompt"),
|
||||
image_count: fields
|
||||
.iter()
|
||||
@@ -515,11 +453,11 @@ fn parse_openai_image_validation_input_from_multipart(
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_openai_image_model_for_operation(model: Option<&str>) -> Option<Option<String>> {
|
||||
let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Some(None);
|
||||
};
|
||||
canonicalize_openai_image_model(model).map(|canonical| Some(canonical.to_string()))
|
||||
fn normalize_openai_image_model_for_operation(model: Option<&str>) -> Option<String> {
|
||||
model
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn count_json_images(object: &serde_json::Map<String, Value>) -> usize {
|
||||
@@ -1090,7 +1028,10 @@ fn estimate_text_tokens(text: &str) -> u64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::estimate_claude_count_tokens;
|
||||
use super::{
|
||||
estimate_claude_count_tokens, parse_openai_image_validation_input, OpenAiImageOperation,
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -1125,4 +1066,19 @@ mod tests {
|
||||
|
||||
assert_eq!(estimate_claude_count_tokens(&payload), Err(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_validation_accepts_custom_model_name() {
|
||||
let body =
|
||||
Bytes::from_static(br#"{"model":" Custom/Image-Model:V1 ","prompt":"draw an image"}"#);
|
||||
|
||||
let validation = parse_openai_image_validation_input(
|
||||
OpenAiImageOperation::Generate,
|
||||
Some("application/json"),
|
||||
&body,
|
||||
)
|
||||
.expect("custom image model should validate");
|
||||
|
||||
assert_eq!(validation.model.as_deref(), Some("Custom/Image-Model:V1"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user