diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 351ffbb52..385dd21cf 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -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, Extension(authenticated): Extension, ) -> Result { - 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 { + 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 { + 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(), diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs new file mode 100644 index 000000000..65716b042 --- /dev/null +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -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 + } + } +}