Files
Genarrative/server-rs/crates/module-runtime/src/agc_models.rs
T
suzmii 4234a21eed 服务端:AGC 模型目录缺配置时改为启动期从上游同步,保留原目录格式
- module-runtime:删除写死的初始目录(高质量→gpt-6-astra、快速→gpt-5.6-luna),新增 from_upstream_models:id 用模型名 slug、alias/modelId 用上游原名、默认项取排序后第一项;字段与校验规则不变
- spacetime-module:read_agc_model_catalog 缺行返回 AGC_MODEL_CATALOG_NOT_INITIALIZED,不再返回内置目录
- api-server:新增启动期 ensure_agc_model_catalog_initialized,未初始化时从分组定价列表 GET /api/pricing?group=taonier 生成目录并按存量 revision 写回;拉不到只记录 error、不写替代目录,下次启动重试
- api-server:目录未初始化时 AGC 目录/对话接口与后台目录接口返回 503,不回落任何内置模型名;后台 PUT 同样要求已初始化
- api-server:上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;目标校验只校验地址,不再绑定已下线的固定模型哨兵;写回冲突后重读校验既有目录
2026-09-24 16:18:06 +08:00

321 lines
12 KiB
Rust

use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// 目录 revision 乐观锁冲突。
pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT";
/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。
pub const AGC_MODEL_CATALOG_NOT_INITIALIZED: &str = "AGC_MODEL_CATALOG_NOT_INITIALIZED";
/// 客户端未显式选择模型时使用的占位标识。
pub const AGC_MODEL_PLATFORM_DEFAULT: &str = "platform-default";
/// 模型标识的长度上限,与客户端 `select_game_creator_model` 的校验保持一致。
pub const AGC_MODEL_ID_MAX_BYTES: usize = 64;
/// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。
pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgcModel {
pub id: String,
pub alias: String,
pub model_id: String,
pub enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgcModelCatalog {
pub revision: u64,
pub default_model_id: String,
pub models: Vec<AgcModel>,
}
impl AgcModelCatalog {
/// 按上游模型列表生成目录:`modelId` 是上游原始模型名,`alias` 也直接用原名
/// (不再填「高质量/快速」这类人工别名),`id` 是模型名的稳定 slug。
///
/// 上游返回顺序不稳定,所以先按原始模型名排序再生成,重复同步得到一致的目录与默认项。
pub fn from_upstream_models(
models: impl IntoIterator<Item = String>,
revision: u64,
) -> Result<Self, String> {
let mut model_names = models
.into_iter()
.map(|model| model.trim().to_string())
.filter(|model| !model.is_empty())
.collect::<Vec<_>>();
model_names.sort();
model_names.dedup();
if model_names.is_empty() {
return Err("上游模型列表为空".into());
}
let mut used_ids = HashSet::new();
let mut entries = Vec::with_capacity(model_names.len());
for model_id in model_names {
let id = unique_model_id(&model_id, &mut used_ids);
entries.push(AgcModel {
id,
alias: model_id.clone(),
model_id,
enabled: true,
});
}
let default_model_id = entries
.first()
.map(|entry| entry.id.clone())
.ok_or_else(|| "上游模型列表为空".to_string())?;
let catalog = Self {
revision,
default_model_id,
models: entries,
};
catalog.validate()?;
Ok(catalog)
}
pub fn validate(&self) -> Result<(), String> {
if self.models.is_empty() || self.models.len() > AGC_MODEL_CATALOG_MAX_MODELS {
return Err(format!(
"模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项"
));
}
let mut ids = HashSet::new();
let mut aliases = HashSet::new();
for model in &self.models {
if model.id.is_empty()
|| model.id == AGC_MODEL_PLATFORM_DEFAULT
|| model.id.len() > AGC_MODEL_ID_MAX_BYTES
|| !model
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|| !ids.insert(&model.id)
{
return Err("模型标识须唯一且只含字母、数字、连字符或下划线".into());
}
if model.alias.trim().is_empty()
|| model.alias != model.alias.trim()
|| model.alias.chars().count() > 40
|| model.alias.chars().any(char::is_control)
|| !aliases.insert(&model.alias)
{
return Err("模型别名须唯一,长度为 1 至 40 字符".into());
}
if model.model_id.trim().is_empty()
|| model.model_id != model.model_id.trim()
|| model.model_id.len() > 200
|| model.model_id.chars().any(char::is_control)
{
return Err("实际模型名不能为空,长度不能超过 200 字节".into());
}
}
self.resolve(&self.default_model_id).map(|_| ())
}
pub fn resolve(&self, id: &str) -> Result<&str, String> {
self.models
.iter()
.find(|m| m.enabled && m.id == id)
.map(|m| m.model_id.as_str())
.ok_or_else(|| "所选模型不可用,请刷新模型列表".into())
}
/// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。
pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> {
let requested = requested
.map(str::trim)
.filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT);
match requested {
Some(id) => self.resolve(id),
None => self.resolve(&self.default_model_id),
}
}
}
/// 由上游模型名生成稳定标识:只保留小写字母、数字、连字符与下划线,其余字符折叠成 `-`。
fn agc_model_id_from_name(model_name: &str) -> String {
let mut id = String::new();
let mut separator_pending = false;
for value in model_name.chars() {
let lowered = value.to_ascii_lowercase();
if lowered.is_ascii_alphanumeric() || lowered == '_' {
if separator_pending && !id.is_empty() {
id.push('-');
}
separator_pending = false;
id.push(lowered);
} else {
separator_pending = true;
}
}
id
}
/// 生成在本次目录内唯一的标识:同名 slug 追加 `-2`/`-3`,并保证不超过长度上限。
fn unique_model_id(model_name: &str, used_ids: &mut HashSet<String>) -> String {
let slug = agc_model_id_from_name(model_name);
let slug = if slug.is_empty() {
"model".to_string()
} else {
slug
};
// 预留后缀空间(`-` 加最多两位序号)后截断,保证候选标识仍在长度上限内。
let base = slug
.char_indices()
.take_while(|(index, _)| *index < AGC_MODEL_ID_MAX_BYTES - 3)
.map(|(_, value)| value)
.collect::<String>();
let base = base.trim_end_matches('-').to_string();
let base = if base.is_empty() {
"model".to_string()
} else {
base
};
let mut candidate = base.clone();
let mut suffix = 2;
while !used_ids.insert(candidate.clone()) {
candidate = format!("{base}-{suffix}");
suffix += 1;
}
candidate
}
#[cfg(test)]
mod tests {
use super::*;
fn upstream(models: &[&str]) -> Vec<String> {
models.iter().map(|model| (*model).to_string()).collect()
}
fn model(id: &str, alias: &str, model_id: &str) -> AgcModel {
AgcModel {
id: id.into(),
alias: alias.into(),
model_id: model_id.into(),
enabled: true,
}
}
#[test]
fn catalog_builds_from_upstream_models_with_stable_ids() {
let catalog = AgcModelCatalog::from_upstream_models(
upstream(&[
" qwen3.8-flash ",
"glm-5.3",
"qwen3.8-flash",
"deepseek-v4-pro",
"",
"vendor/model.v1:latest",
]),
3,
)
.unwrap();
assert_eq!(catalog.revision, 3);
// 默认项是排序后第一项,与上游返回顺序无关。
assert_eq!(catalog.default_model_id, "deepseek-v4-pro");
assert_eq!(
catalog.models,
vec![
model("deepseek-v4-pro", "deepseek-v4-pro", "deepseek-v4-pro"),
model("glm-5-3", "glm-5.3", "glm-5.3"),
model("qwen3-8-flash", "qwen3.8-flash", "qwen3.8-flash"),
model(
"vendor-model-v1-latest",
"vendor/model.v1:latest",
"vendor/model.v1:latest"
),
]
);
assert!(catalog.validate().is_ok());
// 同一模型集合重复生成结果一致。
assert_eq!(
AgcModelCatalog::from_upstream_models(
upstream(&[
"vendor/model.v1:latest",
"deepseek-v4-pro",
"glm-5.3",
"qwen3.8-flash",
]),
3
)
.unwrap(),
catalog
);
assert!(AgcModelCatalog::from_upstream_models(upstream(&["", " "]), 0).is_err());
}
#[test]
fn catalog_keeps_ids_unique_and_within_client_contract() {
// 不同模型名折叠成同一个 slug 时按排序追加序号,且标识始终符合客户端校验。
let catalog = AgcModelCatalog::from_upstream_models(
upstream(&["GLM-5.3", "glm/5.3", "glm_5.3", "模型名"]),
0,
)
.unwrap();
let ids = catalog
.models
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["glm-5-3", "glm-5-3-2", "glm_5-3", "model"]);
for entry in &catalog.models {
assert!(entry.id.len() <= AGC_MODEL_ID_MAX_BYTES);
assert!(
entry
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
);
}
assert!(catalog.validate().is_ok());
}
#[test]
fn catalog_maps_only_enabled_ids() {
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
assert!(catalog.validate().is_ok());
assert_eq!(catalog.resolve("model-a").unwrap(), "model-a");
// 客户端不能直接指定实际模型名,只能回传目录标识。
assert!(catalog.resolve("model-c").is_err());
assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a");
assert_eq!(
catalog
.resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT))
.unwrap(),
"model-a"
);
catalog.models[0].enabled = false;
assert!(catalog.resolve("model-a").is_err());
assert!(catalog.validate().is_err());
}
#[test]
fn catalog_rejects_duplicate_aliases_and_ids() {
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
catalog.models[1].alias = catalog.models[0].alias.clone();
assert!(catalog.validate().is_err());
catalog.models[1].alias = "model-b".into();
catalog.models[1].id = catalog.models[0].id.clone();
assert!(catalog.validate().is_err());
catalog.models[1].id = "model-b".into();
catalog.models[1].id = AGC_MODEL_PLATFORM_DEFAULT.into();
assert!(catalog.validate().is_err());
catalog.models[1].id = "model-b".into();
catalog.models[1].model_id = "".into();
assert!(catalog.validate().is_err());
let too_many = (0..AGC_MODEL_CATALOG_MAX_MODELS + 1)
.map(|index| format!("model-{index}"))
.collect::<Vec<_>>();
assert_eq!(
AgcModelCatalog::from_upstream_models(too_many, 0).unwrap_err(),
format!("模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项")
);
}
}