feat: AGC 支持模型方案选择

新增 Router 模型目录接口并返回可用模型列表

客户端配置新增模型方案与当前选中方案字段

设置页新增模型方案卡片和管理弹层,支持方案名称、模型和推理强度

自定义下拉菜单圆角、遮罩层级与窗口边界定位,并隐藏滚动条

AGC 官方路由保留所选模型并携带客户端标记,由服务端校验后转发

补齐配置结构变更后的既有测试初始化字段
This commit is contained in:
2026-09-04 13:52:00 +08:00
parent 938c37ca1b
commit 4bbef74859
11 changed files with 1342 additions and 35 deletions
@@ -1776,7 +1776,9 @@ impl CodexAppServerConnection {
effective_llm.base_url =
format!("{}/api/llm", session.api_base_url.trim_end_matches('/'));
effective_llm.api_key.clear();
effective_llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string();
// The selected model profile is resolved and validated at config
// load time; the official route still owns the provider/base URL.
effective_llm.model = llm.model.clone();
CodexAppServerCredential::PlatformSession {
fingerprint: format!(
"platform-session:{}:{}:{}",
@@ -137,6 +137,10 @@ async fn proxy_codex_provider_request(
headers.append(name.clone(), value.clone());
}
}
headers.insert(
axum::http::HeaderName::from_static("x-genarrative-client"),
axum::http::HeaderValue::from_static("agc"),
);
let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() {
Ok(value) => value,
Err(_) => {
@@ -3482,7 +3482,23 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr
config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string();
config.llm.api_key.clear();
config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string();
config.llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string();
if let Some(profile) = config
.model_profiles
.iter()
.find(|profile| profile.enabled && profile.id == config.selected_model_profile_id)
{
if !profile.model_id.trim().is_empty() {
config.llm.model = profile.model_id.trim().to_string();
}
if let Some(reasoning_effort) = profile.reasoning_effort.as_deref() {
if !reasoning_effort.trim().is_empty() {
config.llm.reasoning_effort = reasoning_effort.trim().to_string();
}
}
}
if config.llm.model.trim().is_empty() {
config.llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string();
}
config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string();
config.agent_llm.clear();
config.editor_api.api_key.clear();
@@ -3616,6 +3632,19 @@ pub(crate) fn merge_game_creator_config_file(
config.planning.capability_enabled = capability_enabled;
}
}
if let Some(model_profiles) = file_config.model_profiles {
config.model_profiles = model_profiles
.into_iter()
.filter(|profile| {
!profile.id.trim().is_empty()
&& !profile.name.trim().is_empty()
&& !profile.model_id.trim().is_empty()
})
.collect();
}
if let Some(selected_model_profile_id) = file_config.selected_model_profile_id {
config.selected_model_profile_id = selected_model_profile_id;
}
Ok(())
}
@@ -1020,6 +1020,26 @@ struct GameCreatorAppConfigFile {
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
editor_api: Option<GameCreatorEditorApiConfigFile>,
planning: Option<GameCreatorPlanningConfigFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model_profiles: Option<Vec<GameCreatorModelProfileFile>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_profile_id: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorModelProfileFile {
id: String,
name: String,
model_id: String,
#[serde(default = "default_model_profile_enabled")]
enabled: bool,
#[serde(default)]
reasoning_effort: Option<String>,
}
fn default_model_profile_enabled() -> bool {
true
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -1080,6 +1100,14 @@ struct GameCreatorAppConfig {
editor_api: GameCreatorEditorApiConfig,
#[serde(default)]
planning: GameCreatorPlanningConfig,
#[serde(default)]
model_profiles: Vec<GameCreatorModelProfileFile>,
#[serde(default = "default_selected_model_profile_id")]
selected_model_profile_id: String,
}
fn default_selected_model_profile_id() -> String {
"default".to_string()
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -1606,6 +1634,14 @@ impl Default for GameCreatorAppConfig {
agent_llm: BTreeMap::new(),
editor_api: GameCreatorEditorApiConfig::default(),
planning: GameCreatorPlanningConfig::default(),
model_profiles: vec![GameCreatorModelProfileFile {
id: "default".to_string(),
name: "陶泥儿智能创作".to_string(),
model_id: OFFICIAL_LLM_ROUTER_MODEL.to_string(),
enabled: true,
reasoning_effort: Some("max".to_string()),
}],
selected_model_profile_id: default_selected_model_profile_id(),
}
}
}
@@ -737,6 +737,8 @@ fn app_config_commands_write_runtime_config_file() {
},
agent_llm,
planning: GameCreatorPlanningConfig::default(),
model_profiles: Vec::new(),
selected_model_profile_id: "default".to_string(),
})
.expect("write runtime config");
@@ -822,6 +824,8 @@ fn app_config_write_rejects_invalid_api_kind() {
editor_api: GameCreatorEditorApiConfig::default(),
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
model_profiles: Vec::new(),
selected_model_profile_id: "default".to_string(),
});
assert!(result
@@ -847,6 +851,8 @@ fn app_config_write_rejects_invalid_reasoning_effort() {
editor_api: GameCreatorEditorApiConfig::default(),
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
model_profiles: Vec::new(),
selected_model_profile_id: "default".to_string(),
});
assert!(result
@@ -872,6 +878,8 @@ fn app_config_write_rejects_too_small_request_timeout() {
editor_api: GameCreatorEditorApiConfig::default(),
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
model_profiles: Vec::new(),
selected_model_profile_id: "default".to_string(),
});
assert!(result
@@ -718,11 +718,21 @@ export interface GameCreatorAppConfig {
baseUrl: string;
apiKey: string;
};
modelProfiles: GameCreatorModelProfile[];
selectedModelProfileId: string;
planning?: {
capabilityEnabled: boolean;
};
}
export interface GameCreatorModelProfile {
id: string;
name: string;
modelId: string;
enabled: boolean;
reasoningEffort: GameCreatorLlmReasoningEffort;
}
export interface GameCreatorAppConfigView {
path: string;
config: GameCreatorAppConfig;
File diff suppressed because it is too large Load Diff
@@ -179,6 +179,19 @@ export type ClientEditorAssetLibrary = {
}>;
};
export type ClientLlmModel = {
id: string;
capabilities?: string[];
};
export function loadClientLlmModels() {
return requestClientApi<{ models: ClientLlmModel[] }>(
'/api/llm/models',
{ method: 'GET' },
'读取可用模型失败',
).then((response) => response.models ?? []);
}
export function loadEditorAssetLibrary() {
return requestClientApi<{ library: ClientEditorAssetLibrary }>(
'/api/editor/assets/library',
File diff suppressed because it is too large Load Diff
+175 -5
View File
@@ -18,6 +18,19 @@ use shared_contracts::llm::{
use spacetime_client::SpacetimeClientError;
use std::convert::Infallible;
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LlmModelSummary {
pub id: String,
pub capabilities: Vec<String>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmModelsResponse {
models: Vec<LlmModelSummary>,
}
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
@@ -171,6 +184,129 @@ pub async fn proxy_llm_chat_completions(
.into_response())
}
/// Returns the Router model directory through the authenticated platform API.
/// Router credentials and the upstream response body never leave api-server.
pub async fn list_llm_models(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Response, Response> {
let (base_url, api_key, _) =
resolve_llm_router_credentials(&state, authenticated.claims().user_id())
.await
.map_err(|error| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error),
)
})?;
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(15))
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("创建 LLM Router 模型目录客户端失败:{error}")),
)
})?;
let upstream = client
.get(format!("{}/models", base_url.trim_end_matches('/')))
.bearer_auth(api_key)
.send()
.await
.map_err(|error| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("读取 LLM Router 模型目录失败:{error}")),
)
})?;
let status = upstream.status();
if !status.is_success() {
return Err(llm_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("LLM Router 模型目录返回 HTTP {status}")),
));
}
let payload = upstream.json::<Value>().await.map_err(|error| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("解析 LLM Router 模型目录失败:{error}")),
)
})?;
let models = payload
.get("data")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| {
let id = item.get("id").and_then(Value::as_str)?.trim();
let capabilities = item
.get("capabilities")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Some(LlmModelSummary {
id: id.to_string(),
capabilities,
})
})
.filter(|model| !model.id.is_empty())
.collect();
Ok(json_success_body(Some(&request_context), LlmModelsResponse { models }).into_response())
}
async fn router_model_exists(
state: &AppState,
owner_user_id: &str,
model_id: &str,
) -> Result<bool, String> {
let (base_url, api_key, _) = resolve_llm_router_credentials(state, owner_user_id).await?;
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(15))
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("创建 LLM Router 模型校验客户端失败:{error}"))?;
let response = client
.get(format!("{}/models", base_url.trim_end_matches('/')))
.bearer_auth(api_key)
.send()
.await
.map_err(|error| format!("读取 LLM Router 模型目录失败:{error}"))?;
if !response.status().is_success() {
return Err(format!(
"LLM Router 模型目录返回 HTTP {}",
response.status()
));
}
let payload = response
.json::<Value>()
.await
.map_err(|error| format!("解析 LLM Router 模型目录失败:{error}"))?;
Ok(payload
.get("data")
.and_then(Value::as_array)
.is_some_and(|models| {
models.iter().any(|item| {
item.get("id")
.and_then(Value::as_str)
.is_some_and(|id| id.trim() == model_id)
})
}))
}
/// Proxies the OpenAI-compatible Responses protocol for the LLM Router.
///
/// The caller only presents the platform access token. The Router credential
@@ -213,6 +349,12 @@ pub async fn proxy_llm_responses(
.with_message("LLM Responses 请求体必须是 JSON 对象"),
)
})?;
let requested_model = object
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
// The LLM Router is an account-owned route. Ignore legacy client/provider controls;
// they must not reach Router even when an older desktop build still sends
// them. Runtime controls such as `stream`, `input`, `tools` and `metadata`
@@ -232,11 +374,39 @@ pub async fn proxy_llm_responses(
] {
object.remove(field);
}
// Ignore any client model override.
object.insert(
"model".to_string(),
Value::String(state.config.llm_router_model.clone()),
);
// 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.
let agc_client = headers
.get("x-genarrative-client")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "agc");
let selected_model = if agc_client {
if let Some(model) = requested_model {
if router_model_exists(&state, authenticated.claims().user_id(), &model)
.await
.map_err(|error| {
llm_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(error),
)
})?
{
model
} else {
return Err(llm_error_response(
&request_context,
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY)
.with_message("所选模型已不存在,请刷新模型列表后重试"),
));
}
} else {
state.config.llm_router_model.clone()
}
} else {
state.config.llm_router_model.clone()
};
object.insert("model".to_string(), Value::String(selected_model));
let (base_url, api_key, key_id) =
resolve_llm_router_credentials(&state, authenticated.claims().user_id())
@@ -5,7 +5,7 @@ use axum::{
use crate::{
auth::require_bearer_auth,
llm::{proxy_llm_chat_completions, proxy_llm_responses},
llm::{list_llm_models, proxy_llm_chat_completions, proxy_llm_responses},
state::AppState,
volcengine_speech::{
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
@@ -15,6 +15,13 @@ use crate::{
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/llm/models",
get(list_llm_models).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/llm/chat/completions",
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(