6bf57c1e51
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 1m37s
Project CI / AI game creator shell Rust crates (push) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m6s
Project CI / Frontend tests (push) Successful in 2m52s
Project CI / Backend tests (push) Failing after 4m14s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m5s
Project CI / Native shell tests (push) Successful in 8m2s
AgcAgentMode 回退为仅 codex/cc,只表示执行器;新增 AgcModelProtocol 表示线协议 协议取值 openai_responses/openai_chat/anthropic,与执行器组合封闭并在校验时失败关闭 存量目录缺 protocol 时按执行器推导;历史误写的 agentMode=anthropic 读取时归一为 cc 后台 AGC 模型页拆成 Agent 模式与协议两列,新增协议下拉与对应测试 公开目录 /api/llm/models 与后台 DTO 同时下发解析后的 protocol 客户端持久化 selectedModelProtocol,选模型时按执行器推导兜底协议并校验组合 Codex wire_api 按协议在 responses/chat 间切换,不再硬编码 responses 同步 appSurface 与模型选择测试断言,补 protocol 字段 同步模型别名文档与后端架构文档的正交维度说明
509 lines
18 KiB
Rust
509 lines
18 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, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum AgcAgentMode {
|
||
Codex,
|
||
/// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`;
|
||
/// 协议本身由 `AgcModel.protocol` 表达。
|
||
#[serde(alias = "anthropic")]
|
||
Cc,
|
||
}
|
||
|
||
impl Default for AgcAgentMode {
|
||
fn default() -> Self {
|
||
Self::Codex
|
||
}
|
||
}
|
||
|
||
impl AgcAgentMode {
|
||
pub const fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Codex => "codex",
|
||
Self::Cc => "cc",
|
||
}
|
||
}
|
||
|
||
pub fn parse(value: &str) -> Result<Self, String> {
|
||
match value {
|
||
"codex" => Ok(Self::Codex),
|
||
"cc" => Ok(Self::Cc),
|
||
_ => Err("Agent 模式只允许 codex 或 cc".into()),
|
||
}
|
||
}
|
||
|
||
/// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的:
|
||
/// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走
|
||
/// Anthropic Messages。
|
||
pub const fn supports(self, protocol: AgcModelProtocol) -> bool {
|
||
match self {
|
||
Self::Codex => matches!(
|
||
protocol,
|
||
AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat
|
||
),
|
||
Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic),
|
||
}
|
||
}
|
||
|
||
/// 存量目录缺少协议字段时按执行器的默认线协议解释。
|
||
pub const fn default_protocol(self) -> AgcModelProtocol {
|
||
match self {
|
||
Self::Codex => AgcModelProtocol::OpenAiResponses,
|
||
Self::Cc => AgcModelProtocol::Anthropic,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 上游线协议,与执行器正交。
|
||
///
|
||
/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与
|
||
/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的
|
||
/// `/api/llm/anthropic/{*path}`。
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum AgcModelProtocol {
|
||
#[serde(rename = "openai_responses")]
|
||
OpenAiResponses,
|
||
#[serde(rename = "openai_chat")]
|
||
OpenAiChat,
|
||
#[serde(rename = "anthropic")]
|
||
Anthropic,
|
||
}
|
||
|
||
impl Default for AgcModelProtocol {
|
||
fn default() -> Self {
|
||
Self::OpenAiResponses
|
||
}
|
||
}
|
||
|
||
impl AgcModelProtocol {
|
||
pub const fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::OpenAiResponses => "openai_responses",
|
||
Self::OpenAiChat => "openai_chat",
|
||
Self::Anthropic => "anthropic",
|
||
}
|
||
}
|
||
|
||
pub fn parse(value: &str) -> Result<Self, String> {
|
||
match value {
|
||
"openai_responses" => Ok(Self::OpenAiResponses),
|
||
"openai_chat" => Ok(Self::OpenAiChat),
|
||
"anthropic" => Ok(Self::Anthropic),
|
||
_ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
/// 绑定 AGC 客户端实际启动的 Agent,存量目录缺失时兼容为 Codex。
|
||
#[serde(default)]
|
||
pub agent_mode: AgcAgentMode,
|
||
/// 上游线协议。存量目录缺失时按 `agent_mode` 的默认协议解释;
|
||
/// 保存时必须与 `agent_mode` 组成合法组合。
|
||
#[serde(default)]
|
||
pub protocol: Option<AgcModelProtocol>,
|
||
}
|
||
|
||
impl AgcModel {
|
||
/// 显式协议优先,缺失时回退到执行器的默认协议。
|
||
pub fn resolved_protocol(&self) -> AgcModelProtocol {
|
||
self.protocol
|
||
.unwrap_or_else(|| self.agent_mode.default_protocol())
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
agent_mode: AgcAgentMode::default(),
|
||
protocol: None,
|
||
});
|
||
}
|
||
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());
|
||
}
|
||
let protocol = model.resolved_protocol();
|
||
if !model.agent_mode.supports(protocol) {
|
||
return Err(format!(
|
||
"Agent 模式 {} 不支持协议 {}:Codex 只能用 openai_responses / openai_chat,Claude Code 只能用 anthropic",
|
||
model.agent_mode.as_str(),
|
||
protocol.as_str()
|
||
));
|
||
}
|
||
}
|
||
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,
|
||
agent_mode: AgcAgentMode::default(),
|
||
protocol: None,
|
||
}
|
||
}
|
||
|
||
#[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 legacy_catalog_defaults_missing_agent_mode_to_codex() {
|
||
let catalog: AgcModelCatalog = serde_json::from_str(
|
||
r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"#,
|
||
)
|
||
.expect("legacy catalog should remain readable");
|
||
|
||
assert_eq!(catalog.models[0].agent_mode, AgcAgentMode::Codex);
|
||
assert_eq!(catalog.models[0].agent_mode.as_str(), "codex");
|
||
}
|
||
|
||
#[test]
|
||
fn catalog_agent_mode_rejects_values_outside_codex_and_cc() {
|
||
let mut catalog =
|
||
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
|
||
catalog.models[0].agent_mode = AgcAgentMode::Cc;
|
||
assert!(catalog.validate().is_ok());
|
||
assert_eq!(catalog.models[0].agent_mode.as_str(), "cc");
|
||
assert!(AgcAgentMode::parse("anthropic").is_err());
|
||
|
||
let invalid: Result<AgcModelCatalog, _> = serde_json::from_str(
|
||
r#"{"revision":0,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"provider"}]}"#,
|
||
);
|
||
assert!(invalid.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn protocol_is_orthogonal_to_agent_mode_and_pairs_are_closed() {
|
||
// 协议缺省按执行器推导:codex -> openai_responses,cc -> anthropic。
|
||
let mut catalog =
|
||
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
|
||
assert_eq!(
|
||
catalog.models[0].resolved_protocol(),
|
||
AgcModelProtocol::OpenAiResponses
|
||
);
|
||
catalog.models[0].agent_mode = AgcAgentMode::Cc;
|
||
assert_eq!(
|
||
catalog.models[0].resolved_protocol(),
|
||
AgcModelProtocol::Anthropic
|
||
);
|
||
assert_eq!(
|
||
AgcModelProtocol::parse("anthropic").unwrap().as_str(),
|
||
"anthropic"
|
||
);
|
||
|
||
// codex 支持两种 OpenAI 线协议。
|
||
catalog.models[0].agent_mode = AgcAgentMode::Codex;
|
||
catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiChat);
|
||
assert!(catalog.validate().is_ok());
|
||
catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiResponses);
|
||
assert!(catalog.validate().is_ok());
|
||
|
||
// 非法组合必须失败关闭:codex + anthropic。
|
||
catalog.models[0].protocol = Some(AgcModelProtocol::Anthropic);
|
||
let error = catalog
|
||
.validate()
|
||
.expect_err("codex must reject the anthropic protocol");
|
||
assert!(error.contains("不支持协议"), "{error}");
|
||
|
||
// cc 只支持 anthropic。
|
||
catalog.models[0].agent_mode = AgcAgentMode::Cc;
|
||
catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiChat);
|
||
assert!(catalog.validate().is_err());
|
||
catalog.models[0].protocol = Some(AgcModelProtocol::Anthropic);
|
||
assert!(catalog.validate().is_ok());
|
||
}
|
||
|
||
#[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} 项")
|
||
);
|
||
}
|
||
}
|