feat(会员): AGC 模型代理按会员档位过滤与校验

- 新增 llm/model_access.rs:用 get_profile_recharge_center 解析账号档位到 AgcModelAccess,缺会员行/缺目录行/读失败统一失败关闭到 Basic
- GET /api/llm/models 按档过滤并给出该档默认模型;该档无可用模型时返回 503 而不是不可解析的默认项
- /api/llm/responses、/api/llm/chat/completions、/api/llm/anthropic/* 全部改按档解析;越权返回 403 + MODEL_NOT_AVAILABLE_FOR_PLAN,目录外/停用仍是 422
- 补 api-server 单测:Basic 档过滤 Full 模型并回退默认项、越权错误码与状态映射
This commit is contained in:
2026-10-03 16:36:05 +08:00
parent 2630b0f690
commit 3306d932bb
2 changed files with 176 additions and 41 deletions
+121 -41
View File
@@ -9,6 +9,7 @@ use axum::{
},
};
use futures_util::StreamExt;
use module_runtime::AgcModelResolveError;
use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest};
use serde_json::{Value, json};
use shared_contracts::llm::{
@@ -42,6 +43,10 @@ mod anthropic_bridge;
pub(crate) mod icon_specs;
mod model_access;
use model_access::{OwnerAgcModelCatalog, resolve_owner_agc_model_access};
#[cfg(test)]
mod model_catalog_tests {
use super::*;
@@ -54,7 +59,14 @@ mod model_catalog_tests {
)
.expect("catalog should build");
catalog.models[1].enabled = false;
let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap();
let payload = serde_json::to_value(
public_model_catalog(super::OwnerAgcModelCatalog {
catalog,
access: module_runtime::AgcModelAccess::Full,
})
.unwrap(),
)
.unwrap();
// 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。
assert_eq!(
payload["models"],
@@ -66,6 +78,44 @@ mod model_catalog_tests {
assert!(payload["models"][0].get("enabled").is_none());
assert!(payload["models"][0].get("modelId").is_none());
}
#[test]
fn public_catalog_filters_full_models_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,
)
.expect("catalog should build");
// 登记的默认项是 Full;Basic 档看不到它,默认项回退到目录顺序里第一个可用项。
catalog.models[0].access = module_runtime::AgcModelAccess::Full;
let payload = serde_json::to_value(
public_model_catalog(super::OwnerAgcModelCatalog {
catalog,
access: module_runtime::AgcModelAccess::Basic,
})
.unwrap(),
)
.unwrap();
assert_eq!(
payload["models"],
json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}])
);
assert_eq!(payload["defaultModelId"], "gpt-5-6-terra");
}
#[test]
fn plan_violation_maps_to_forbidden_with_dedicated_code() {
let violation =
agc_model_resolve_error(module_runtime::AgcModelResolveError::NotAvailableForPlan);
assert_eq!(violation.status_code(), StatusCode::FORBIDDEN);
assert_eq!(
violation.code(),
module_runtime::MODEL_NOT_AVAILABLE_FOR_PLAN
);
let unavailable =
agc_model_resolve_error(module_runtime::AgcModelResolveError::Unavailable);
assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
}
}
#[cfg(test)]
@@ -195,32 +245,45 @@ pub async fn list_llm_models(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Response, Response> {
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
let owner_catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id())
.await
.map_err(|error| llm_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), public_model_catalog(catalog)).into_response())
let catalog = public_model_catalog(owner_catalog)
.map_err(|error| llm_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), catalog).into_response())
}
fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsResponse {
LlmModelsResponse {
default_model_id: catalog.default_model_id,
models: catalog
.models
.into_iter()
.filter(|model| model.enabled)
.map(|model| {
let protocol = model.resolved_protocol().as_str().to_string();
LlmModelSummary {
id: model.id,
// 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
display_name: model.alias,
agent_mode: model.agent_mode.as_str().to_string(),
protocol,
}
})
.collect(),
fn public_model_catalog(
owner_catalog: OwnerAgcModelCatalog,
) -> Result<LlmModelsResponse, AppError> {
let OwnerAgcModelCatalog { catalog, access } = owner_catalog;
// 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。
let default_model_id = catalog
.default_model_id_for(access)
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_code("MODEL_UNAVAILABLE")
.with_message("当前会员档位没有可用模型")
})?
.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,
}
})
.collect();
Ok(LlmModelsResponse {
default_model_id,
models,
revision: catalog.revision,
}
})
}
/// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」,
@@ -262,6 +325,27 @@ async fn load_llm_catalog(
crate::agc_models::load_catalog(state).await
}
/// 全局目录 + 账号权限档;所有对外 LLM 路由都经它解析模型。
async fn load_owner_llm_catalog(
state: &AppState,
owner: &str,
) -> Result<OwnerAgcModelCatalog, AppError> {
let catalog = load_llm_catalog(state, owner).await?;
let access = resolve_owner_agc_model_access(state, owner).await;
Ok(OwnerAgcModelCatalog { catalog, access })
}
/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用仍是 422。
fn agc_model_resolve_error(error: AgcModelResolveError) -> AppError {
let status = match error {
AgcModelResolveError::NotAvailableForPlan => StatusCode::FORBIDDEN,
AgcModelResolveError::Unavailable => StatusCode::UNPROCESSABLE_ENTITY,
};
AppError::from_status(status)
.with_code(error.code())
.with_message(error.message())
}
/// Proxies the OpenAI-compatible Responses protocol for the LLM Router.
///
/// The caller only presents the platform access token. The Router credential
@@ -334,7 +418,7 @@ pub async fn proxy_llm_responses(
.get("x-genarrative-client")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "agc");
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id())
.await
.map_err(|error| llm_error_response(&request_context, error))?;
let requested_model = if agc_client {
@@ -343,13 +427,9 @@ pub async fn proxy_llm_responses(
None
};
let selected_model = catalog
.resolve_requested(requested_model)
.map_err(|message| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message),
)
})?
.catalog
.resolve_requested_for(requested_model, catalog.access)
.map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))?
.to_string();
object.insert("model".to_string(), Value::String(selected_model.clone()));
@@ -550,21 +630,17 @@ pub async fn proxy_llm_messages(
] {
object.remove(field);
}
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id())
.await
.map_err(|error| llm_error_response(&request_context, error))?;
// 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补
// `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识;
// 目录外取值仍由 `resolve_requested` 拒绝。
// 目录外取值与档位不足都由 `resolve_requested_for` 拒绝。
let requested_model = requested_model.as_deref();
let selected_model = catalog
.resolve_requested(requested_model)
.map_err(|message| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message),
)
})?
.catalog
.resolve_requested_for(requested_model, catalog.access)
.map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))?
.to_string();
// 原生直通要用目录解析后的上游模型名;桥接路径由转换器显式接收同一个名字。
object.insert("model".to_string(), Value::String(selected_model.clone()));
@@ -1402,10 +1478,14 @@ async fn resolve_llm_router_client(
owner_user_id: &str,
) -> Result<(platform_llm::LlmClient, String), String> {
let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id).await?;
let catalog = load_llm_catalog(state, owner_user_id)
let catalog = load_owner_llm_catalog(state, owner_user_id)
.await
.map_err(|_| "模型目录暂不可用".to_string())?;
let model = catalog.resolve_requested(None)?;
// Chat Completions 的 `model` 恒为账号该档的默认模型,不接收客户端选择。
let model = catalog
.catalog
.resolve_requested_for(None, catalog.access)
.map_err(|error| error.message().to_string())?;
let config = platform_llm::LlmConfig::new(
platform_llm::LlmProvider::OpenAiCompatible,
base_url.to_string(),
@@ -0,0 +1,55 @@
//! 账号会员档位 → AGC 模型权限档的解析(BFF 侧,带 IO)。
//!
//! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成
//! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。
//!
//! 失败关闭:会员行缺失、目录行缺失、或 SpacetimeDB 读失败时都按 `Normal`(`Basic`)处理,
//! 只放宽到基础模型,不放开高性能模型。
use crate::state::AppState;
use module_runtime::{AgcModelAccess, AgcModelCatalog};
/// 账号视角的 AGC 模型目录:全局目录 + 该账号的权限档。
pub(crate) struct OwnerAgcModelCatalog {
pub(crate) catalog: AgcModelCatalog,
pub(crate) access: AgcModelAccess,
}
/// 解析账号的 AGC 模型权限档。
///
/// 走现有 `get_profile_recharge_center` 读模型(它同时返回会员行与档位目录行);
/// 该读带幂等的账期刷新写入,后续可替换为更轻的专用读。
pub(crate) async fn resolve_owner_agc_model_access(
state: &AppState,
owner_user_id: &str,
) -> AgcModelAccess {
#[cfg(test)]
if super::test_provisioned_router_credentials()
.lock()
.expect("fixture lock")
.contains_key(owner_user_id)
{
// 测试夹具目录里的模型全是缺省 `Basic`,返回 `Full` 以避免夹具再去连 SpacetimeDB。
return AgcModelAccess::Full;
}
match state
.spacetime_client()
.get_profile_recharge_center(owner_user_id.to_string())
.await
{
Ok(center) => center
.membership_plans
.iter()
.find(|row| row.plan == center.membership.plan)
.map(|row| AgcModelAccess::from(row.model_access))
.unwrap_or(AgcModelAccess::Basic),
Err(error) => {
tracing::warn!(
user_id = %owner_user_id,
error = %error,
"读取会员档位失败,AGC 模型权限按 Basic 失败关闭"
);
AgcModelAccess::Basic
}
}
}