feat(saas): add model groups for cross-provider failover
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

Model Groups provide logical model names that map to multiple physical
models across providers, with automatic failover when one provider's
key pool is exhausted.

Backend:
- New model_groups + model_group_members tables with FK constraints
- Full CRUD API (7 endpoints) with admin-only write permissions
- Cache layer: DashMap-backed CachedModelGroup with load_from_db
- Relay integration: ModelResolution enum for Direct/Group routing
- Cross-provider failover: sort_candidates_by_quota + OnceLock cache
- Relay failure path: record failure usage + relay_dequeue (fixes
  queue counter leak that caused connection pool exhaustion)
- add_group_member: validate model_id exists before insert

Frontend:
- saas-relay-client: accept getModel() callback for dynamic model selection
- connectionStore: prefer conversationStore.currentModel over first available

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-04 09:56:21 +08:00
parent 9af7b0dd46
commit be0a78a523
11 changed files with 849 additions and 64 deletions

View File

@@ -491,3 +491,167 @@ fn mask_api_key(key: &str) -> String {
}
format!("{}...{}", &key[..4], &key[key.len()-4..])
}
// ============ Model Groups ============
pub async fn list_model_groups(db: &PgPool) -> SaasResult<Vec<ModelGroupInfo>> {
let group_rows: Vec<(String, String, String, String, bool, String, String, String)> = sqlx::query_as(
"SELECT id, name, display_name, COALESCE(description, ''), enabled,
COALESCE(failover_strategy, 'quota_aware'), created_at, updated_at
FROM model_groups ORDER BY name"
).fetch_all(db).await?;
let member_rows: Vec<(String, String, String, String, i32, bool)> = sqlx::query_as(
"SELECT id, group_id, provider_id, model_id, priority, enabled
FROM model_group_members ORDER BY priority ASC"
).fetch_all(db).await?;
let groups = group_rows.into_iter().map(|(id, name, display_name, description, enabled, failover_strategy, created_at, updated_at)| {
let members: Vec<ModelGroupMemberInfo> = member_rows.iter()
.filter(|(_, gid, _, _, _, _)| gid == &id)
.map(|(mid, _, pid, mid2, pri, en)| ModelGroupMemberInfo {
id: mid.clone(),
provider_id: pid.clone(),
model_id: mid2.clone(),
priority: *pri,
enabled: *en,
})
.collect();
ModelGroupInfo { id, name, display_name, description, enabled, failover_strategy, members, created_at, updated_at }
}).collect();
Ok(groups)
}
pub async fn get_model_group(db: &PgPool, group_id: &str) -> SaasResult<ModelGroupInfo> {
let row: Option<(String, String, String, String, bool, String, String, String)> = sqlx::query_as(
"SELECT id, name, display_name, COALESCE(description, ''), enabled,
COALESCE(failover_strategy, 'quota_aware'), created_at, updated_at
FROM model_groups WHERE id = $1"
).bind(group_id).fetch_optional(db).await?;
let (id, name, display_name, description, enabled, failover_strategy, created_at, updated_at) =
row.ok_or_else(|| SaasError::NotFound(format!("模型组 {} 不存在", group_id)))?;
let member_rows: Vec<(String, String, String, String, i32, bool)> = sqlx::query_as(
"SELECT id, group_id, provider_id, model_id, priority, enabled
FROM model_group_members WHERE group_id = $1 ORDER BY priority ASC"
).bind(group_id).fetch_all(db).await?;
let members = member_rows.into_iter()
.map(|(mid, _, pid, mid2, pri, en)| ModelGroupMemberInfo {
id: mid, provider_id: pid, model_id: mid2, priority: pri, enabled: en,
})
.collect();
Ok(ModelGroupInfo { id, name, display_name, description, enabled, failover_strategy, members, created_at, updated_at })
}
pub async fn create_model_group(db: &PgPool, req: &CreateModelGroupRequest) -> SaasResult<ModelGroupInfo> {
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
// 检查名称唯一性
let existing: Option<(String,)> = sqlx::query_as("SELECT id FROM model_groups WHERE name = $1")
.bind(&req.name).fetch_optional(db).await?;
if existing.is_some() {
return Err(SaasError::AlreadyExists(format!("模型组 '{}' 已存在", req.name)));
}
// 名称不能和已有 model_id 冲突(避免路由歧义)
let model_conflict: Option<(String,)> = sqlx::query_as("SELECT model_id FROM models WHERE model_id = $1")
.bind(&req.name).fetch_optional(db).await?;
if model_conflict.is_some() {
return Err(SaasError::InvalidInput(
format!("模型组名称 '{}' 与已有模型 ID 冲突,请使用不同的名称", req.name)
));
}
sqlx::query(
"INSERT INTO model_groups (id, name, display_name, description, failover_strategy, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)"
)
.bind(&id).bind(&req.name).bind(&req.display_name).bind(&req.description)
.bind(&req.failover_strategy).bind(&now)
.execute(db).await?;
get_model_group(db, &id).await
}
pub async fn update_model_group(
db: &PgPool, group_id: &str, req: &UpdateModelGroupRequest,
) -> SaasResult<ModelGroupInfo> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE model_groups SET
display_name = COALESCE($1, display_name),
description = COALESCE($2, description),
enabled = COALESCE($3, enabled),
failover_strategy = COALESCE($4, failover_strategy),
updated_at = $5
WHERE id = $6"
)
.bind(req.display_name.as_deref())
.bind(req.description.as_deref())
.bind(req.enabled)
.bind(req.failover_strategy.as_deref())
.bind(&now)
.bind(group_id)
.execute(db).await?;
get_model_group(db, group_id).await
}
pub async fn delete_model_group(db: &PgPool, group_id: &str) -> SaasResult<()> {
let result = sqlx::query("DELETE FROM model_groups WHERE id = $1")
.bind(group_id).execute(db).await?;
if result.rows_affected() == 0 {
return Err(SaasError::NotFound(format!("模型组 {} 不存在", group_id)));
}
Ok(())
}
pub async fn add_group_member(
db: &PgPool, group_id: &str, req: &AddGroupMemberRequest,
) -> SaasResult<ModelGroupMemberInfo> {
// 验证 group 存在
sqlx::query_scalar::<_, String>("SELECT id FROM model_groups WHERE id = $1")
.bind(group_id).fetch_optional(db).await?
.ok_or_else(|| SaasError::NotFound(format!("模型组 {} 不存在", group_id)))?;
// 验证 provider 存在
sqlx::query_scalar::<_, String>("SELECT id FROM providers WHERE id = $1")
.bind(&req.provider_id).fetch_optional(db).await?
.ok_or_else(|| SaasError::NotFound(format!("Provider {} 不存在", req.provider_id)))?;
// 验证 model 存在(避免插入无效 model_id 导致 relay 运行时找不到模型)
sqlx::query_scalar::<_, String>("SELECT model_id FROM models WHERE model_id = $1")
.bind(&req.model_id).fetch_optional(db).await?
.ok_or_else(|| SaasError::NotFound(format!("模型 {} 不存在", req.model_id)))?;
let id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO model_group_members (id, group_id, provider_id, model_id, priority, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())"
)
.bind(&id).bind(group_id).bind(&req.provider_id).bind(&req.model_id).bind(req.priority)
.execute(db).await?;
Ok(ModelGroupMemberInfo {
id,
provider_id: req.provider_id.clone(),
model_id: req.model_id.clone(),
priority: req.priority,
enabled: true,
})
}
pub async fn remove_group_member(db: &PgPool, member_id: &str) -> SaasResult<()> {
let result = sqlx::query("DELETE FROM model_group_members WHERE id = $1")
.bind(member_id).execute(db).await?;
if result.rows_affected() == 0 {
return Err(SaasError::NotFound(format!("成员 {} 不存在", member_id)));
}
Ok(())
}