diff --git a/server-rs/crates/api-server/server-rs/.data/tracking-outbox/active.ndjson b/server-rs/crates/api-server/server-rs/.data/tracking-outbox/active.ndjson new file mode 100644 index 000000000..4eeddd031 --- /dev/null +++ b/server-rs/crates/api-server/server-rs/.data/tracking-outbox/active.ndjson @@ -0,0 +1 @@ +{"event":{"event_id":"api:llm_request:1790222962549945:8f53fb57-089a-4fcf-80e1-af28a0656277","event_key":"llm_request","scope_kind":"User","scope_id":"user_0925081091da44008877a289086dd191","user_id":"user_0925081091da44008877a289086dd191","owner_user_id":"user_0925081091da44008877a289086dd191","profile_id":null,"module_key":"llm","metadata_json":"{\"method\":\"POST\",\"operation\":\"POST /api/llm/chat/completions\",\"route\":\"/api/llm/chat/completions\",\"status\":200}","occurred_at_micros":1790222962549945}} diff --git a/server-rs/crates/api-server/server-rs/.data/tracking-outbox/sealed-1790222962550816-72e2cdf9-d339-47a8-a222-ab2104b7622c.ndjson b/server-rs/crates/api-server/server-rs/.data/tracking-outbox/sealed-1790222962550816-72e2cdf9-d339-47a8-a222-ab2104b7622c.ndjson new file mode 100644 index 000000000..cef519082 --- /dev/null +++ b/server-rs/crates/api-server/server-rs/.data/tracking-outbox/sealed-1790222962550816-72e2cdf9-d339-47a8-a222-ab2104b7622c.ndjson @@ -0,0 +1 @@ +{"event":{"event_id":"api:llm_request:1790222962539120:399eeb15-2cfe-4512-a20d-96d2d8e33551","event_key":"llm_request","scope_kind":"User","scope_id":"user_1b8a6a89f772430b80b983b209be8973","user_id":"user_1b8a6a89f772430b80b983b209be8973","owner_user_id":"user_1b8a6a89f772430b80b983b209be8973","profile_id":null,"module_key":"llm","metadata_json":"{\"method\":\"POST\",\"operation\":\"POST /api/llm/chat/completions\",\"route\":\"/api/llm/chat/completions\",\"status\":200}","occurred_at_micros":1790222962539120}} diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs index dbd83c0cc..8acf73b5e 100644 --- a/server-rs/crates/api-server/src/agc_models.rs +++ b/server-rs/crates/api-server/src/agc_models.rs @@ -7,26 +7,190 @@ use axum::{ extract::{Extension, State}, http::StatusCode, }; -use module_runtime::AgcModelCatalog; +use module_runtime::{ + AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcModelCatalog, +}; use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog}; +use spacetime_client::SpacetimeClientError; +use std::time::Duration; +use tracing::warn; + +/// 目录未初始化时对外统一的失败文案:目录只能来自上游同步或后台保存。 +pub(crate) const AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE: &str = + "模型目录未初始化,服务端正在尝试从上游同步,请稍后重试"; +/// 上游模型列表请求超时与响应大小上限;越界按同步失败处理。 +const AGC_MODEL_LIST_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const AGC_MODEL_LIST_MAX_BYTES: usize = 1024 * 1024; + +/// 只读 `revision`:存量目录可能是旧结构,覆盖写入时仍需对齐乐观锁版本。 +#[derive(serde::Deserialize)] +struct StoredCatalogRevision { + revision: u64, +} pub(crate) async fn load_catalog(state: &AppState) -> Result { - let json = state - .spacetime_client() - .read_agc_model_catalog() - .await - .map_err(|_| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用") - })?; - let catalog: AgcModelCatalog = serde_json::from_str(&json).map_err(|_| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录格式无效") - })?; - catalog.validate().map_err(|message| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) + let stored = read_stored_catalog(state).await.map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用") })?; + let Some(json) = stored else { + return Err(uninitialized_error()); + }; + parse_catalog(&json).map_err(|_| uninitialized_error()) +} + +fn uninitialized_error() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message(AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE) +} + +/// 解析并校验目录内容;存量旧结构(`id`/`alias`/`modelId`)在这里失败,按未初始化处理。 +fn parse_catalog(json: &str) -> Result { + let catalog: AgcModelCatalog = + serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?; + catalog.validate()?; Ok(catalog) } +async fn read_stored_catalog(state: &AppState) -> Result, SpacetimeClientError> { + match state.spacetime_client().read_agc_model_catalog().await { + Ok(json) => Ok(Some(json)), + Err(SpacetimeClientError::Procedure(message)) + if message == AGC_MODEL_CATALOG_NOT_INITIALIZED => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +/// 启动期确保目录已初始化:未初始化时从上游模型列表重建,失败只返回错误由调用方记录。 +/// +/// 目录已可用时不做任何写入;只有缺失、结构不符或校验不通过才重建,因此上游模型 +/// 变化不会自动覆盖后台维护过的目录。 +pub(crate) async fn ensure_agc_model_catalog_initialized(state: &AppState) -> Result<(), String> { + let stored = read_stored_catalog(state) + .await + .map_err(|error| format!("读取 AGC 模型目录失败:{error}"))?; + let revision = match stored.as_deref() { + Some(json) => match parse_catalog(json) { + Ok(_) => return Ok(()), + Err(message) => { + warn!( + error = %message, + "AGC 模型目录内容与当前定义不符,按未初始化处理并从上游重建" + ); + stored_catalog_revision(json) + } + }, + None => Some(0), + }; + let revision = revision.ok_or_else(|| { + "存量 AGC 模型目录缺少可解析的 revision,需要先清理该行再重启".to_string() + })?; + + let models = fetch_upstream_model_names(state).await?; + let catalog = AgcModelCatalog::from_upstream_models(models, revision)?; + let payload = + serde_json::to_string(&catalog).map_err(|_| "AGC 模型目录序列化失败".to_string())?; + match state + .spacetime_client() + .save_agc_model_catalog(payload) + .await + { + Ok(saved) => { + let saved: AgcModelCatalog = serde_json::from_str(&saved) + .map_err(|_| "AGC 模型目录写回结果格式无效".to_string())?; + tracing::info!( + revision = saved.revision, + model_count = saved.models.len(), + "已按上游模型列表初始化 AGC 模型目录" + ); + Ok(()) + } + // 多实例同时启动时只有一个写入成功,其余接受既有目录。 + Err(SpacetimeClientError::Procedure(message)) if message == AGC_MODEL_CATALOG_CONFLICT => { + Ok(()) + } + Err(error) => Err(format!("写入 AGC 模型目录失败:{error}")), + } +} + +fn stored_catalog_revision(json: &str) -> Option { + serde_json::from_str::(json) + .ok() + .map(|stored| stored.revision) +} + +/// 上游 OpenAI 兼容模型列表:`GET {LLM Router 地址}/models`,Bearer 为 Router 管理员 Token。 +async fn fetch_upstream_model_names(state: &AppState) -> Result, String> { + crate::external_api_keys::ensure_llm_router_target_allowed(state)?; + let token = state + .config + .llm_router_admin_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "缺少 LLM Router 管理员 Token,无法从上游同步 AGC 模型目录".to_string())?; + let url = format!( + "{}/models", + state.config.llm_router_base_url.trim_end_matches('/') + ); + let url = reqwest::Url::parse(url.as_str()) + .map_err(|error| format!("LLM Router 模型列表地址无效:{error}"))?; + let client = reqwest::Client::builder() + .timeout(AGC_MODEL_LIST_REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("构建 LLM Router 客户端失败:{error}"))?; + let response = client + .get(url) + .bearer_auth(token) + .send() + .await + .map_err(|error| format!("请求上游模型列表失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("上游模型列表返回 HTTP {status}")); + } + let bytes = read_bounded_json_body(response).await?; + let payload: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| "上游模型列表格式无效".to_string())?; + parse_upstream_model_names(&payload) +} + +async fn read_bounded_json_body(response: reqwest::Response) -> Result, String> { + if response + .content_length() + .is_some_and(|length| length > AGC_MODEL_LIST_MAX_BYTES as u64) + { + return Err("上游模型列表响应超过大小上限".to_string()); + } + let bytes = response + .bytes() + .await + .map_err(|error| format!("读取上游模型列表失败:{error}"))?; + if bytes.len() > AGC_MODEL_LIST_MAX_BYTES { + return Err("上游模型列表响应超过大小上限".to_string()); + } + Ok(bytes.to_vec()) +} + +fn parse_upstream_model_names(payload: &serde_json::Value) -> Result, String> { + let data = payload + .get("data") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "上游模型列表缺少 data 数组".to_string())?; + let models = data + .iter() + .filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str)) + .map(str::to_string) + .collect::>(); + if models.iter().all(|model| model.trim().is_empty()) { + return Err("上游模型列表为空".to_string()); + } + Ok(models) +} + pub async fn admin_get_agc_models( State(state): State, Extension(context): Extension, @@ -46,15 +210,13 @@ pub async fn admin_save_agc_models( ) -> Result, AppError> { let catalog = AgcModelCatalog { revision: payload.revision, - default_model_id: payload.default_model_id, + default_model: payload.default_model, models: payload .models .into_iter() - .map(|m| module_runtime::AgcModel { - id: m.id, - alias: m.alias, - model_id: m.model_id, - enabled: m.enabled, + .map(|model| module_runtime::AgcModel { + model: model.model, + enabled: model.enabled, }) .collect(), }; @@ -68,7 +230,7 @@ pub async fn admin_save_agc_models( .save_agc_model_catalog(payload) .await .map_err(|error| { - if matches!(error, spacetime_client::SpacetimeClientError::Procedure(ref message) if message == module_runtime::AGC_MODEL_CATALOG_CONFLICT) { + if matches!(&error, SpacetimeClientError::Procedure(message) if message == AGC_MODEL_CATALOG_CONFLICT) { AppError::from_status(StatusCode::CONFLICT).with_message("模型目录已被更新,请重新读取") } else { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("保存模型目录失败,请稍后重试") @@ -82,16 +244,166 @@ pub async fn admin_save_agc_models( fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { AdminAgcModelCatalog { revision: catalog.revision, - default_model_id: catalog.default_model_id, + default_model: catalog.default_model, models: catalog .models .into_iter() - .map(|m| AdminAgcModel { - id: m.id, - alias: m.alias, - model_id: m.model_id, - enabled: m.enabled, + .map(|model| AdminAgcModel { + model: model.model, + enabled: model.enabled, }) .collect(), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn upstream_model_names_come_from_openai_compatible_data_array() { + let payload = json!({ + "object": "list", + "data": [ + {"id": "gpt-5.6-sol", "object": "model"}, + {"id": "gpt-5.6-terra", "object": "model"}, + {"object": "model"} + ] + }); + assert_eq!( + parse_upstream_model_names(&payload).unwrap(), + vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()] + ); + + assert_eq!( + parse_upstream_model_names(&json!({"data": []})).unwrap_err(), + "上游模型列表为空" + ); + assert_eq!( + parse_upstream_model_names(&json!({"data": [{"id": " "}]})).unwrap_err(), + "上游模型列表为空" + ); + assert!(parse_upstream_model_names(&json!({"object": "list"})).is_err()); + } + + #[test] + fn stored_catalog_revision_reads_legacy_rows() { + assert_eq!( + stored_catalog_revision( + r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"# + ), + Some(4) + ); + assert_eq!(stored_catalog_revision("not json"), None); + assert_eq!(stored_catalog_revision(r#"{"models":[]}"#), None); + } + + #[test] + fn legacy_catalog_content_is_treated_as_uninitialized() { + let legacy = r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"#; + assert!(parse_catalog(legacy).is_err()); + + let current = + AgcModelCatalog::from_upstream_models(vec!["model-a".to_string()], 4).unwrap(); + assert_eq!( + parse_catalog(&serde_json::to_string(¤t).unwrap()).unwrap(), + current + ); + } + + struct MockModelListServer { + base_url: String, + captured: std::sync::Arc>>, + _handle: std::thread::JoinHandle<()>, + } + + fn spawn_mock_model_list_server(status_line: &str, body: &str) -> MockModelListServer { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("mock listener binds"); + let address = listener.local_addr().expect("mock address"); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); + let captured_for_thread = std::sync::Arc::clone(&captured); + let response = format!( + "HTTP/1.1 {status_line}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("mock accept"); + let mut buffer = [0u8; 8192]; + let read = stream.read(&mut buffer).unwrap_or_default(); + *captured_for_thread.lock().expect("captured lock") = + Some(String::from_utf8_lossy(&buffer[..read]).to_string()); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + }); + MockModelListServer { + base_url: format!("http://{address}/v1"), + captured, + _handle: handle, + } + } + + fn model_list_state(base_url: &str, admin_token: Option<&str>) -> AppState { + AppState::new(crate::config::AppConfig { + llm_router_base_url: base_url.to_string(), + llm_router_admin_token: admin_token.map(str::to_string), + ..crate::config::AppConfig::default() + }) + .expect("state should build") + } + + #[tokio::test] + async fn fetch_upstream_model_names_uses_admin_token_and_parses_openai_list() { + let server = spawn_mock_model_list_server( + "200 OK", + &json!({"object": "list", "data": [{"id": "model-a"}, {"id": "model-b"}]}).to_string(), + ); + let state = model_list_state(&server.base_url, Some("admin-token")); + + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap(), + vec!["model-a".to_string(), "model-b".to_string()] + ); + + let request = server + .captured + .lock() + .expect("captured lock") + .clone() + .expect("mock server should capture request"); + assert!(request.starts_with("GET /v1/models HTTP/1.1"), "{request}"); + assert!( + request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-token")), + "{request}" + ); + } + + #[tokio::test] + async fn fetch_upstream_model_names_fails_closed_without_token_or_models() { + let unauthorized = spawn_mock_model_list_server("401 Unauthorized", "{}"); + let state = model_list_state(&unauthorized.base_url, Some("admin-token")); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表返回 HTTP 401 Unauthorized" + ); + + let empty = spawn_mock_model_list_server("200 OK", &json!({"data": []}).to_string()); + let state = model_list_state(&empty.base_url, Some("admin-token")); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表为空" + ); + + let state = model_list_state("http://127.0.0.1:1/v1", None); + assert!( + fetch_upstream_model_names(&state) + .await + .unwrap_err() + .contains("管理员 Token") + ); + } +} diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 3e70d8e5f..6e4bc5d8e 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -1643,7 +1643,7 @@ fn router_control_origin(base_url: &str) -> Result { Ok(url.to_string().trim_end_matches('/').to_string()) } -fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { +pub(crate) fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { let base_url = state.config.llm_router_base_url.trim_end_matches('/'); let url = reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?; diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 32ee19402..98edbffa8 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -37,18 +37,23 @@ mod model_catalog_tests { use super::*; #[test] - fn public_catalog_only_exposes_alias_and_stable_id() { - let mut catalog = module_runtime::AgcModelCatalog::default(); - catalog.revision = 7; + fn public_catalog_exposes_upstream_model_names() { + let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( + vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], + 7, + ) + .expect("catalog should build"); catalog.models[1].enabled = false; let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap(); + // DTO 形状固定为 id/displayName/defaultModelId/revision,两者都是上游原始模型名。 assert_eq!( payload["models"], - json!([{"id": "quality", "displayName": "高质量"}]) + json!([{"id": "gpt-5.6-sol", "displayName": "gpt-5.6-sol"}]) ); - assert_eq!(payload["defaultModelId"], "quality"); + assert_eq!(payload["defaultModelId"], "gpt-5.6-sol"); assert_eq!(payload["revision"], json!(7)); - assert!(!payload.to_string().contains("gpt-")); + assert!(payload.get("defaultModel").is_none()); + assert!(payload["models"][0].get("enabled").is_none()); } } @@ -187,20 +192,32 @@ pub async fn list_llm_models( fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsResponse { LlmModelsResponse { - default_model_id: catalog.default_model_id, + default_model_id: catalog.default_model, models: catalog .models .into_iter() .filter(|model| model.enabled) .map(|model| LlmModelSummary { - id: model.id, - display_name: model.alias, + // DTO 形状保持不变:`id` 与 `displayName` 都是上游原始模型名, + // 因此存量客户端无需发版即可显示原名。 + id: model.model.clone(), + display_name: model.model, }) .collect(), revision: catalog.revision, } } +/// 测试用目录:只有平台目录模式才会命中的固定模型名。 +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_NAME: &str = "test-router-model"; + +#[cfg(test)] +pub(crate) fn test_agc_model_catalog() -> module_runtime::AgcModelCatalog { + module_runtime::AgcModelCatalog::from_upstream_models(vec![TEST_AGC_MODEL_NAME.to_string()], 0) + .expect("test catalog should build") +} + async fn load_llm_catalog( state: &AppState, owner: &str, @@ -211,7 +228,7 @@ async fn load_llm_catalog( .expect("fixture lock") .contains_key(owner) { - return Ok(module_runtime::AgcModelCatalog::default()); + return Ok(test_agc_model_catalog()); } let _ = owner; crate::agc_models::load_catalog(state).await @@ -283,9 +300,8 @@ pub async fn proxy_llm_responses( ] { object.remove(field); } - // The AGC client may select a model from the server-provided Router - // directory. Older callers without the reserved marker remain pinned to - // the official default model. + // AGC 客户端可以在服务端目录内选择模型;`model` 就是上游原始模型名。 + // 老客户端存的历史稳定标识与目录外模型一律拒绝,不回退其它模型。 let agc_client = headers .get("x-genarrative-client") .and_then(|value| value.to_str().ok()) @@ -293,16 +309,13 @@ pub async fn proxy_llm_responses( let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; - let selected_id = if agc_client { - requested_model - .as_deref() - .filter(|id| *id != "platform-default") + let requested_model = if agc_client { + requested_model.as_deref() } else { None - } - .unwrap_or(&catalog.default_model_id); + }; let selected_model = catalog - .resolve(selected_id) + .resolve_requested(requested_model) .map_err(|message| { llm_error_response( &request_context, @@ -847,7 +860,7 @@ async fn resolve_llm_router_client( let catalog = load_llm_catalog(state, owner_user_id) .await .map_err(|_| "模型目录暂不可用".to_string())?; - let model = catalog.resolve(&catalog.default_model_id)?; + let model = catalog.resolve_requested(None)?; let config = platform_llm::LlmConfig::new( platform_llm::LlmProvider::OpenAiCompatible, base_url.to_string(), @@ -1304,11 +1317,14 @@ mod tests { } #[tokio::test] - async fn llm_responses_proxy_forces_official_model_and_keeps_router_key_server_side() { + async fn llm_responses_without_agc_marker_uses_catalog_default_and_keeps_router_key_server_side() + { let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", - body: r#"{"id":"resp_proxy_01","model":"gpt-6-astra","output":[]}"#.to_string(), + body: format!( + r#"{{"id":"resp_proxy_01","model":"{TEST_AGC_MODEL_NAME}","output":[]}}"# + ), extra_headers: Vec::new(), }); let (state, user_id) = seed_authenticated_state(AppConfig { @@ -1373,12 +1389,63 @@ mod tests { .expect("upstream request body"); let upstream_payload: Value = serde_json::from_str(upstream_body).expect("upstream body should be json"); - assert_eq!(upstream_payload["model"], "gpt-6-astra"); + assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_NAME); assert_ne!(upstream_payload["model"], "client-must-not-control"); } #[tokio::test] - async fn llm_responses_rejects_upstream_names_and_unknown_catalog_ids() { + async fn llm_responses_forwards_catalog_model_selected_by_agc_client() { + let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: format!( + r#"{{"id":"resp_proxy_02","model":"{TEST_AGC_MODEL_NAME}","output":[]}}"# + ), + extra_headers: Vec::new(), + }); + let (state, user_id) = seed_authenticated_state(AppConfig { + llm_router_base_url: server_url.clone(), + llm_router_api_key_encryption_secret: Some("fixture-encryption-secret".to_string()), + ..AppConfig::default() + }) + .await; + install_test_provisioned_router_credential(&user_id, server_url, "fixture-router-key"); + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/responses") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-client", "agc") + .header("content-type", "application/json") + .body(Body::from( + json!({"model": TEST_AGC_MODEL_NAME, "input": "hello"}).to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + + let upstream_request = captured_request + .lock() + .expect("captured request lock") + .clone() + .expect("mock server should capture upstream request"); + let (_, upstream_body) = upstream_request + .split_once("\r\n\r\n") + .expect("upstream request body"); + let upstream_payload: Value = + serde_json::from_str(upstream_body).expect("upstream body should be json"); + // 目录内模型名原样透传:客户端不再需要“稳定标识 → 实际模型名”的二次映射。 + assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_NAME); + } + + #[tokio::test] + async fn llm_responses_rejects_models_outside_catalog() { let (state, user_id) = seed_authenticated_state(AppConfig::default()).await; install_test_provisioned_router_credential( &user_id, @@ -1387,7 +1454,8 @@ mod tests { ); let token = issue_access_token(&state, &user_id); let app = build_router(state); - for model in ["gpt-6-astra", "unlisted"] { + // 旧稳定标识、已从目录移除的上游名称、未知名称都必须拒绝,不回退其它模型。 + for model in ["quality", "gpt-6-astra", "unlisted"] { let response = app .clone() .oneshot( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 241551c49..8515721f5 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -500,6 +500,10 @@ fn should_initialize_editor_generation_pricing_for_startup(process_role: Process process_role.runs_http() } +fn should_initialize_agc_model_catalog_for_startup(process_role: ProcessRole) -> bool { + process_role.runs_http() +} + async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { let bind_address = config.bind_socket_addr(); let listen_backlog = config.listen_backlog; @@ -764,6 +768,16 @@ async fn try_restore_app_state_for_startup( )) })?; } + // AGC 模型目录只来自上游同步或后台保存;这里同步失败不阻塞启动,由下一次启动重试, + // 未初始化期间 AGC 相关接口失败关闭。 + if should_initialize_agc_model_catalog_for_startup(process_role) { + if let Err(error) = crate::agc_models::ensure_agc_model_catalog_initialized(&state).await { + error!( + error = %error, + "AGC 模型目录未初始化:本次启动未从上游同步到模型列表,AGC 目录与对话接口将失败关闭,下次启动会重试" + ); + } + } Ok(state) } diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 74c9f96ec..faf9802e1 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -1,118 +1,196 @@ 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"; +/// 上游模型名的字节上限。 +pub const AGC_MODEL_NAME_MAX_BYTES: usize = 200; -#[derive(Clone, Debug, Serialize, Deserialize)] +/// 平台开放给 AGC 的单个模型:`model` 就是上游原始模型名。 +#[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 model: String, pub enabled: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +/// AGC 模型目录:上游模型白名单 + 启用状态 + 默认项。 +#[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 default_model: String, pub models: Vec, } -impl Default for AgcModelCatalog { - fn default() -> Self { - Self { - revision: 0, - default_model_id: "quality".into(), - models: vec![ - AgcModel { - id: "quality".into(), - alias: "高质量".into(), - model_id: "gpt-6-astra".into(), - enabled: true, - }, - AgcModel { - id: "fast".into(), - alias: "快速".into(), - model_id: "gpt-5.6-luna".into(), - enabled: true, - }, - ], - } - } -} - impl AgcModelCatalog { - pub fn validate(&self) -> Result<(), String> { - if self.models.is_empty() || self.models.len() > 32 { - return Err("模型列表必须包含 1 至 32 项".into()); + /// 按上游模型列表建目录:剔除空白项、去重保序、全部启用,默认项取首个模型。 + pub fn from_upstream_models( + models: impl IntoIterator, + revision: u64, + ) -> Result { + let mut seen = HashSet::new(); + let mut entries = Vec::new(); + for model in models { + let model = model.trim(); + if model.is_empty() || !seen.insert(model.to_string()) { + continue; + } + entries.push(AgcModel { + model: model.to_string(), + enabled: true, + }); } - let mut ids = HashSet::new(); - let mut aliases = HashSet::new(); - for model in &self.models { - if model.id.is_empty() - || model.id == "platform-default" - || model.id.len() > 64 - || !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()); - } - } - self.resolve(&self.default_model_id).map(|_| ()) + let default_model = entries + .first() + .map(|entry| entry.model.clone()) + .ok_or_else(|| "上游模型列表为空".to_string())?; + let catalog = Self { + revision, + default_model, + models: entries, + }; + catalog.validate()?; + Ok(catalog) } - pub fn resolve(&self, id: &str) -> Result<&str, String> { + pub fn validate(&self) -> Result<(), String> { + if self.models.is_empty() { + return Err("模型目录必须至少包含一项".into()); + } + let mut names = HashSet::new(); + for model in &self.models { + let name = model.model.as_str(); + if name.is_empty() + || name != name.trim() + || name.len() > AGC_MODEL_NAME_MAX_BYTES + || name.chars().any(char::is_control) + || name == AGC_MODEL_PLATFORM_DEFAULT + || !names.insert(name) + { + return Err(format!( + "模型名须唯一、非空、不含首尾空白与控制字符,且不超过 {AGC_MODEL_NAME_MAX_BYTES} 字节" + )); + } + } + let default_model = self + .models + .iter() + .find(|model| model.model == self.default_model) + .ok_or_else(|| "默认模型必须是目录中的模型".to_string())?; + if !default_model.enabled { + return Err("默认模型必须是启用项".to_string()); + } + Ok(()) + } + + /// 目录外或已停用的模型一律拒绝。 + pub fn resolve(&self, model: &str) -> Result<&str, String> { self.models .iter() - .find(|m| m.enabled && m.id == id) - .map(|m| m.model_id.as_str()) + .find(|entry| entry.enabled && entry.model == model) + .map(|entry| entry.model.as_str()) .ok_or_else(|| "所选模型不可用,请刷新模型列表".into()) } + + /// 请求侧解析:显式选择按目录校验,未选择或占位标识使用目录默认项。 + pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> { + let requested = requested + .map(str::trim) + .filter(|model| !model.is_empty() && *model != AGC_MODEL_PLATFORM_DEFAULT); + match requested { + Some(model) => self.resolve(model), + None => self.resolve(&self.default_model), + } + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn catalog_maps_only_enabled_ids() { - let mut catalog = AgcModelCatalog::default(); - assert!(catalog.validate().is_ok()); - assert_eq!(catalog.resolve("quality").unwrap(), "gpt-6-astra"); - assert!(catalog.resolve("gpt-6-astra").is_err()); - assert!(catalog.resolve("unknown").is_err()); - catalog.models[0].enabled = false; - assert!(catalog.resolve("quality").is_err()); - assert!(catalog.validate().is_err()); + fn upstream(models: &[&str]) -> Vec { + models.iter().map(|model| (*model).to_string()).collect() } #[test] - fn catalog_rejects_duplicate_aliases_and_ids() { - let mut catalog = AgcModelCatalog::default(); - catalog.models[1].alias = catalog.models[0].alias.clone(); + fn catalog_builds_from_upstream_models_in_upstream_order() { + let catalog = AgcModelCatalog::from_upstream_models( + upstream(&[" gpt-5.6-sol ", "gpt-5.6-terra", "gpt-5.6-sol", ""]), + 3, + ) + .unwrap(); + assert_eq!(catalog.revision, 3); + assert_eq!(catalog.default_model, "gpt-5.6-sol"); + assert_eq!( + catalog.models, + vec![ + AgcModel { + model: "gpt-5.6-sol".into(), + enabled: true, + }, + AgcModel { + model: "gpt-5.6-terra".into(), + enabled: true, + }, + ] + ); + assert!(catalog.validate().is_ok()); + assert!(AgcModelCatalog::from_upstream_models(upstream(&["", " "]), 0).is_err()); + } + + #[test] + fn catalog_resolves_only_enabled_models() { + let mut catalog = + AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); + assert_eq!(catalog.resolve("model-a").unwrap(), "model-a"); + assert_eq!( + catalog.resolve_requested(Some("model-b")).unwrap(), + "model-b" + ); + assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a"); + assert_eq!( + catalog + .resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT)) + .unwrap(), + "model-a" + ); + assert!(catalog.resolve("model-c").is_err()); + // 历史稳定标识不再可用:目录里的就是上游模型名本身。 + assert!(catalog.resolve("quality").is_err()); + + catalog.models[0].enabled = false; assert!(catalog.validate().is_err()); - catalog.models[1].alias = "快速".into(); - catalog.models[1].id = catalog.models[0].id.clone(); + assert!(catalog.resolve_requested(None).is_err()); + assert!(catalog.resolve("model-a").is_err()); + } + + #[test] + fn catalog_rejects_invalid_and_duplicate_model_names() { + let mut catalog = + AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); + + catalog.default_model = "model-c".into(); + assert_eq!( + catalog.validate().unwrap_err(), + "默认模型必须是目录中的模型" + ); + + catalog.default_model = "model-a".into(); + catalog.models[1].model = "model-a".into(); assert!(catalog.validate().is_err()); + + catalog.models[1].model = format!("model-{}", "x".repeat(AGC_MODEL_NAME_MAX_BYTES)); + assert!(catalog.validate().is_err()); + + catalog.models[1].model = AGC_MODEL_PLATFORM_DEFAULT.into(); + assert!(catalog.validate().is_err()); + + catalog.models.clear(); + assert_eq!(catalog.validate().unwrap_err(), "模型目录必须至少包含一项"); } } diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index e8e0c4637..9129f372d 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1487,13 +1487,11 @@ mod tests { assert!(value.get("image_sequence_duration_ms").is_none()); } } -/// AGC 模型目录仅在后台返回实际模型名。 +/// AGC 模型目录项:`model` 就是上游原始模型名,客户端与后台都显示它。 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AdminAgcModel { - pub id: String, - pub alias: String, - pub model_id: String, + pub model: String, pub enabled: bool, } @@ -1501,7 +1499,7 @@ pub struct AdminAgcModel { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AdminAgcModelCatalog { pub revision: u64, - pub default_model_id: String, + pub default_model: String, pub models: Vec, } diff --git a/server-rs/crates/spacetime-module/src/agc_models.rs b/server-rs/crates/spacetime-module/src/agc_models.rs index 5fba815ab..03bbb3536 100644 --- a/server-rs/crates/spacetime-module/src/agc_models.rs +++ b/server-rs/crates/spacetime-module/src/agc_models.rs @@ -16,16 +16,14 @@ pub fn read_agc_model_catalog(ctx: &mut ProcedureContext) -> Result