feat(会员): 模型列表返回可用与不可用分桶

- shared-contracts 新增 llm_catalog.rs:LlmModelsResponse/LlmModelSummary/LlmUnavailableModel/LlmModelUnavailableReason(plan_required/disabled/unknown),未停用优先于档位的 disabled 语义留给域层
- AgcAgentMode/AgcModelProtocol 从 module-runtime 迁入 shared-contracts(前后端 + ts-rs 单一真源),module-runtime re-export 保持路径可用
- 整个目录 DTO 与枚举走 ts-rs,生成到 packages/shared/src/contracts/generated,revision 标 number
- module-runtime 新增 AgcModelCatalog::unavailable_models_for(access):保持目录顺序,停用优先于档位不足,补单测
- api-server public_model_catalog 产出 models + unavailableModels 两桶,重写 model_catalog_tests
This commit is contained in:
2026-10-03 18:03:20 +08:00
parent 35a54d19e2
commit e89420053a
12 changed files with 393 additions and 124 deletions
+31 -12
View File
@@ -14,7 +14,7 @@ use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest};
use serde_json::{Value, json};
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
LlmModelSummary, LlmModelsResponse,
LlmModelSummary, LlmModelsResponse, LlmUnavailableModel,
};
use spacetime_client::SpacetimeClientError;
use std::convert::Infallible;
@@ -52,7 +52,7 @@ mod model_catalog_tests {
use super::*;
#[test]
fn public_catalog_exposes_stable_id_and_upstream_alias() {
fn public_catalog_splits_available_and_disabled_models() {
let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models(
vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()],
7,
@@ -72,15 +72,21 @@ mod model_catalog_tests {
payload["models"],
json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses"}])
);
// 后台停用的模型仍下发到不可用桶,带 disabled 原因。
assert_eq!(
payload["unavailableModels"],
json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses", "reason": "disabled"}])
);
assert_eq!(payload["defaultModelId"], "gpt-5-6-sol");
assert_eq!(payload["revision"], json!(7));
assert!(payload.get("defaultModel").is_none());
assert!(payload["models"][0].get("enabled").is_none());
assert!(payload["models"][0].get("modelId").is_none());
assert!(payload["unavailableModels"][0].get("modelId").is_none());
}
#[test]
fn public_catalog_filters_full_models_for_basic_plan() {
fn public_catalog_puts_full_models_in_plan_required_bucket_for_basic_plan() {
let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models(
vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()],
0,
@@ -100,6 +106,10 @@ mod model_catalog_tests {
payload["models"],
json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}])
);
assert_eq!(
payload["unavailableModels"],
json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses", "reason": "plan_required"}])
);
assert_eq!(payload["defaultModelId"], "gpt-5-6-terra");
}
@@ -268,20 +278,29 @@ fn public_model_catalog(
.to_string();
let models = catalog
.available_models_for(access)
.map(|model| {
let protocol = model.resolved_protocol().as_str().to_string();
LlmModelSummary {
id: model.id.clone(),
// 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
display_name: model.alias.clone(),
agent_mode: model.agent_mode.as_str().to_string(),
protocol,
}
.map(|model| LlmModelSummary {
id: model.id.clone(),
// 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
display_name: model.alias.clone(),
agent_mode: model.agent_mode,
protocol: model.resolved_protocol(),
})
.collect();
// 不可用桶只是展示信息:含后台停用与本档档位不够,保持目录顺序。
let unavailable_models = catalog
.unavailable_models_for(access)
.map(|(model, reason)| LlmUnavailableModel {
id: model.id.clone(),
display_name: model.alias.clone(),
agent_mode: model.agent_mode,
protocol: model.resolved_protocol(),
reason,
})
.collect();
Ok(LlmModelsResponse {
default_model_id,
models,
unavailable_models,
revision: catalog.revision,
})
}
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::{
AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess,
};
use shared_contracts::llm_catalog::LlmModelUnavailableReason;
/// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。
pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN";
@@ -105,6 +106,26 @@ impl AgcModelCatalog {
.filter(move |model| model.is_available_for(access))
}
/// 该档位**不可用**的模型(含后台停用与本档档位不够),保持目录顺序。
///
/// 与 [`Self::available_models_for`] 互补:两桶并集是目录全集、互不相交。同时停用且
/// 档位不够时取 [`LlmModelUnavailableReason::Disabled`]——升级也解锁不了,不能标
/// `plan_required` 误导用户。
pub fn unavailable_models_for(
&self,
access: AgcModelAccess,
) -> impl Iterator<Item = (&AgcModel, LlmModelUnavailableReason)> {
self.models.iter().filter_map(move |model| {
if !model.enabled {
Some((model, LlmModelUnavailableReason::Disabled))
} else if !access.allows(model.access) {
Some((model, LlmModelUnavailableReason::PlanRequired))
} else {
None
}
})
}
/// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时
/// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。
pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> {
@@ -287,4 +308,40 @@ mod tests {
"MODEL_UNAVAILABLE"
);
}
#[test]
fn unavailable_models_for_splits_disabled_and_plan_gated_in_catalog_order() {
let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 0);
mark_full(&mut catalog, "model-b");
mark_full(&mut catalog, "model-c");
catalog.models[2].enabled = false;
let unavailable = catalog
.unavailable_models_for(AgcModelAccess::Basic)
.map(|(model, reason)| (model.id.as_str(), reason))
.collect::<Vec<_>>();
// model-c 同时停用且 Full:reason 取 disabled(升级也解锁不了)。
assert_eq!(
unavailable,
vec![
("model-b", LlmModelUnavailableReason::PlanRequired),
("model-c", LlmModelUnavailableReason::Disabled),
]
);
// Full 档只剩停用的 model-c;可用集合与不可用集合互补。
let unavailable_full = catalog
.unavailable_models_for(AgcModelAccess::Full)
.map(|(model, reason)| (model.id.as_str(), reason))
.collect::<Vec<_>>();
assert_eq!(
unavailable_full,
vec![("model-c", LlmModelUnavailableReason::Disabled)]
);
let available = catalog
.available_models_for(AgcModelAccess::Basic)
.map(|model| model.id.as_str())
.collect::<Vec<_>>();
assert_eq!(available, vec!["model-a"]);
}
}
@@ -14,99 +14,9 @@ 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()),
}
}
}
// 执行器与线协议枚举已迁到 `shared-contracts`(前后端 + ts-rs 单一真源);
// 这里 re-export,保持 `module_runtime::AgcAgentMode` / `AgcModelProtocol` 路径可用。
pub use shared_contracts::llm_catalog::{AgcAgentMode, AgcModelProtocol};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -23,5 +23,6 @@ pub mod game_creation_app;
pub mod game_distribution;
pub mod hyper3d;
pub mod llm;
pub mod llm_catalog;
pub mod payment;
pub mod runtime;
+6 -19
View File
@@ -60,22 +60,9 @@ mod tests {
assert_eq!(payload["messages"][0]["role"], json!("system"));
}
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LlmModelSummary {
pub display_name: String,
pub id: String,
/// 执行器:`codex` 或 `cc`。
pub agent_mode: String,
/// 上游线协议,与执行器正交:`openai_responses` / `openai_chat` / `anthropic`。
pub protocol: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LlmModelsResponse {
pub default_model_id: String,
pub models: Vec<LlmModelSummary>,
/// 模型目录版本,客户端据此做条件刷新。
pub revision: u64,
}
// 目录 DTO 与枚举已迁到 `llm_catalog`(同一 ts-rs 生成目录);这里 re-export 保持既有
// `shared_contracts::llm::*` 路径可用。
pub use crate::llm_catalog::{
AgcAgentMode, AgcModelProtocol, LlmModelSummary, LlmModelUnavailableReason, LlmModelsResponse,
LlmUnavailableModel,
};
@@ -0,0 +1,226 @@
//! AGC LLM 模型目录的对外契约与枚举(前后端唯一真源)。
//!
//! `GET /api/llm/models` 返回两个平行数组:`models` 只放本档可用模型;
//! `unavailableModels` 放目录里其余全部(含后台停用与本档档位不够),元素带 `reason`。
//! 整个 DTO 与枚举经 ts-rs 导出到 `packages/shared/src/contracts/generated/`,
//! AGC 前端不再手写第二份类型。
use serde::{Deserialize, Serialize};
/// 客户端启动的执行器。与线协议正交。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(
export,
export_to = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../packages/shared/src/contracts/generated/"
)
)
)]
pub enum AgcAgentMode {
Codex,
/// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`;
/// 协议本身由 [`AgcModelProtocol`] 表达。
#[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)]
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(
export,
export_to = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../packages/shared/src/contracts/generated/"
)
)
)]
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")]
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(
export,
export_to = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../packages/shared/src/contracts/generated/"
)
)
)]
pub struct LlmModelSummary {
pub display_name: String,
pub id: String,
/// 执行器;旧服务端缺字段时按 `codex` 兼容。
#[serde(default)]
pub agent_mode: AgcAgentMode,
/// 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。
#[serde(default)]
pub protocol: AgcModelProtocol,
}
/// 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(
export,
export_to = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../packages/shared/src/contracts/generated/"
)
)
)]
pub struct LlmUnavailableModel {
pub display_name: String,
pub id: String,
#[serde(default)]
pub agent_mode: AgcAgentMode,
#[serde(default)]
pub protocol: AgcModelProtocol,
pub reason: LlmModelUnavailableReason,
}
/// 模型对当前账号不可用的原因。
///
/// `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(
export,
export_to = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../packages/shared/src/contracts/generated/"
)
)
)]
pub enum LlmModelUnavailableReason {
/// 会员档位不够(升级可解锁)。
PlanRequired,
/// 后台人工停用(升级也解锁不了)。
Disabled,
/// 未知原因的前向兜底;当前服务端不会主动产出。
#[serde(other)]
Unknown,
}
/// `GET /api/llm/models` 的响应。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
#[cfg_attr(
feature = "ts-bindings",
ts(
export,
export_to = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../packages/shared/src/contracts/generated/"
)
)
)]
pub struct LlmModelsResponse {
pub default_model_id: String,
/// 本档可用,保持目录顺序。
pub models: Vec<LlmModelSummary>,
/// 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。
#[serde(default)]
pub unavailable_models: Vec<LlmUnavailableModel>,
/// 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。
#[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
pub revision: u64,
}