AGC 模型目录改为启动期从上游同步:退役写死目录与别名词义

- module-runtime:目录收敛为 {model, enabled} + defaultModel + revision,删除写死的高质量/快速默认目录,新增 from_upstream_models / resolve_requested
- spacetime-module:read_agc_model_catalog 缺行返回 AGC_MODEL_CATALOG_NOT_INITIALIZED,不再兜底返回内置目录
- api-server:新增启动期 ensure_agc_model_catalog_initialized,按 Router 管理员 Token 调上游 GET {base}/models 建目录并写回存量 revision;失败只记录 error 且不写替代目录
- api-server:公开目录 id/displayName 改为上游原始模型名,请求侧按目录校验原名透传,platform-default 与缺省使用默认项,目录外模型 422
- shared-contracts:后台目录 DTO 改为 {revision, defaultModel, models:[{model, enabled}]}
This commit is contained in:
2026-09-24 12:10:30 +08:00
parent 819505dc2b
commit 57a5cd0c9c
9 changed files with 615 additions and 145 deletions
@@ -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}}
@@ -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}}
+338 -26
View File
@@ -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<AgcModelCatalog, AppError> {
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<AgcModelCatalog, String> {
let catalog: AgcModelCatalog =
serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?;
catalog.validate()?;
Ok(catalog)
}
async fn read_stored_catalog(state: &AppState) -> Result<Option<String>, 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<u64> {
serde_json::from_str::<StoredCatalogRevision>(json)
.ok()
.map(|stored| stored.revision)
}
/// 上游 OpenAI 兼容模型列表:`GET {LLM Router 地址}/models`,Bearer 为 Router 管理员 Token。
async fn fetch_upstream_model_names(state: &AppState) -> Result<Vec<String>, 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<Vec<u8>, 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<Vec<String>, 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::<Vec<_>>();
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<AppState>,
Extension(context): Extension<RequestContext>,
@@ -46,15 +210,13 @@ pub async fn admin_save_agc_models(
) -> Result<Json<serde_json::Value>, 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(&current).unwrap()).unwrap(),
current
);
}
struct MockModelListServer {
base_url: String,
captured: std::sync::Arc<std::sync::Mutex<Option<String>>>,
_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")
);
}
}
@@ -1643,7 +1643,7 @@ fn router_control_origin(base_url: &str) -> Result<String, String> {
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}"))?;
+94 -26
View File
@@ -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(
+14
View File
@@ -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)
}
+159 -81
View File
@@ -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<AgcModel>,
}
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<Item = String>,
revision: u64,
) -> Result<Self, String> {
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<String> {
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(), "模型目录必须至少包含一项");
}
}
@@ -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<AdminAgcModel>,
}
@@ -16,16 +16,14 @@ pub fn read_agc_model_catalog(ctx: &mut ProcedureContext) -> Result<String, Stri
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
Ok(tx
.db
// 目录只有一份事实来源:上游同步或后台保存。缺行时不得返回任何内置目录,
// 由 api-server 按“未初始化”处理并触发启动期同步。
tx.db
.agc_model_catalog()
.id()
.find(0)
.map(|row| row.catalog_json)
.unwrap_or_else(|| {
serde_json::to_string(&module_runtime::AgcModelCatalog::default())
.expect("default catalog")
}))
.ok_or_else(|| module_runtime::AGC_MODEL_CATALOG_NOT_INITIALIZED.to_string())
})
}