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:
@@ -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}}
|
||||
+1
@@ -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}}
|
||||
@@ -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(¤t).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}"))?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user