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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user