feat: AGC 支持模型方案选择
新增 Router 模型目录接口并返回可用模型列表 客户端配置新增模型方案与当前选中方案字段 设置页新增模型方案卡片和管理弹层,支持方案名称、模型和推理强度 自定义下拉菜单圆角、遮罩层级与窗口边界定位,并隐藏滚动条 AGC 官方路由保留所选模型并携带客户端标记,由服务端校验后转发 补齐配置结构变更后的既有测试初始化字段
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user