AGC 官方 LLM Router 账号链路与流式联网输出 #242

Merged
kdletters merged 59 commits from feat/agc-llm-router-official-chain into master 2026-09-06 16:49:13 +08:00
6 changed files with 12 additions and 59 deletions
Showing only changes of commit 9a06945e44 - Show all commits
-5
View File
@@ -8,11 +8,6 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1"
# but it should not be relied on by browser code.
LLM_API_KEY=""
# Router account provisioning secret (server-side only). Prefer the protected
# file form in production; never expose either value to clients or commit it.
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET=""
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=""
# Optional frontend override for the local proxy path.
VITE_LLM_PROXY_BASE_URL="/api/llm"
-1
View File
@@ -84,7 +84,6 @@ GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
# LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。
GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret
GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret
GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
@@ -1380,4 +1380,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
- 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。
- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`
> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。provisioning secret 仅从部署侧受保护环境变量或 secret file 读取,不再内置源码常量
> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。考虑到公共 Router 与独立部署数据库当前必须共享同一派生根,provisioning secret 阶段性固定在 api-server 服务端实现;这是当前阶段的安全与部署取舍,不是设计缺陷,后续统一密钥管理和账号迁移方案落地后再迁移
@@ -215,7 +215,7 @@ SpacetimeDB procedure
### LLM Router 账号 Key 与 Router 代理
普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API KeyRouter 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username``password``display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id`部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 TokenToken/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API KeyRouter 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username``password``display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` api-server 内置 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致。该 secret 是公共 Router 多部署共享场景下的阶段性安全与部署取舍,不是设计缺陷;后续统一密钥管理和账号迁移方案落地后再迁移。完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 TokenToken/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
```text
AGC loopback Provider ProxyBearer=平台 access token
+1 -7
View File
@@ -186,8 +186,7 @@ pub struct AppConfig {
/// LLM Router 账号级代理固定配置;普通客户端不能覆盖这些值。
pub llm_router_base_url: String,
pub llm_router_model: String,
/// Router 用户密码派生根,仅从部署侧 secret/env/file 读取
pub llm_router_provisioning_secret: Option<String>,
/// Router 凭据本地加密密钥;缺省时从 JWT secret 做域分离派生
pub llm_router_api_key_encryption_secret: Option<String>,
pub llm_router_admin_token: Option<String>,
pub dashscope_base_url: String,
@@ -494,7 +493,6 @@ impl Default for AppConfig {
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
llm_router_base_url: OFFICIAL_LLM_ROUTER_BASE_URL.to_string(),
llm_router_model: OFFICIAL_LLM_ROUTER_MODEL.to_string(),
llm_router_provisioning_secret: None,
llm_router_api_key_encryption_secret: None,
llm_router_admin_token: None,
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
@@ -1212,10 +1210,6 @@ impl AppConfig {
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
}
config.llm_router_provisioning_secret = read_secret_env_or_file(
&["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET"],
&["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE"],
);
config.llm_router_api_key_encryption_secret = read_secret_env_or_file(
&["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET"],
&["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE"],
@@ -56,6 +56,12 @@ const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor";
const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router";
const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:";
const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1;
// 阶段性取舍而非设计缺陷:公共 Router 与独立部署数据库当前仍需共享同一
// 派生根。暂时将 provisioning secret 固定在 api-server 服务端,避免把它
// 暴露为客户端或部署侧可随意覆盖的配置项;后续统一密钥管理和账号迁移方案
// 落地后,再按 credential version 迁移,不能静默造成已有账号密码漂移。
const LLM_ROUTER_PROVISIONING_SECRET_V1: &[u8] =
b"Jq67uCuRN1fs1l08e9ayImp4Uzc4TAY1IaP0UmHz5qTM7pQnEgwzFYhF_SPmK1Bm";
type HmacSha256 = Hmac<Sha256>;
@@ -342,11 +348,7 @@ pub(crate) async fn ensure_llm_router_account(
// safely replaced with the current deterministic values. Unknown remote
// outcomes with a known account id remain reconciliation-blocked above.
let expected_username = router_username_for_owner(owner_user_id);
let provisioning_secret = llm_router_provisioning_secret(state)?;
let expected_password = generate_router_account_password_with_secret(
owner_user_id,
provisioning_secret.as_bytes(),
)?;
let expected_password = generate_router_account_password(owner_user_id)?;
let stale_pending_credentials = persisted.as_ref().is_some_and(|account| {
account.router_account_id.is_none()
&& bundle.as_ref().is_some_and(|value| {
1
@@ -1349,10 +1351,7 @@ async fn provision_router_account_via_new_api(
.or_else(|| pending_login.map(|login| (login.username.clone(), login.password.clone())))
.unwrap_or((
router_username_for_owner(owner_user_id),
generate_router_account_password_with_secret(
owner_user_id,
llm_router_provisioning_secret(state)?.as_bytes(),
)?,
generate_router_account_password(owner_user_id)?,
));
let admin_token = state
.config
@@ -1976,47 +1975,13 @@ fn router_username_for_owner(owner_user_id: &str) -> String {
format!("agc_user_{short_code}")
}
fn llm_router_provisioning_secret(state: &AppState) -> Result<String, String> {
if let Some(secret) = state
.config
.llm_router_provisioning_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(secret.to_string());
}
#[cfg(test)]
{
return Ok("test-only-router-provisioning-secret".to_string());
}
#[cfg(not(test))]
{
Err("LLM Router 账号密码派生密钥未配置".to_string())
}
}
#[cfg(test)]
fn generate_router_account_password(owner_user_id: &str) -> Result<String, String> {
generate_router_account_password_with_secret(
owner_user_id,
b"test-only-router-provisioning-secret",
)
}
fn generate_router_account_password_with_secret(
owner_user_id: &str,
provisioning_secret: &[u8],
) -> Result<String, String> {
let owner_user_id = owner_user_id.trim();
if owner_user_id.is_empty() {
return Err("LLM Router 账号密码派生参数缺失".to_string());
}
if provisioning_secret.is_empty() {
return Err("LLM Router 账号密码派生密钥未配置".to_string());
}
let mut signer = HmacSha256::new_from_slice(provisioning_secret)
let mut signer = HmacSha256::new_from_slice(LLM_ROUTER_PROVISIONING_SECRET_V1)
.map_err(|_| "LLM Router 账号密码派生密钥无效".to_string())?;
signer.update(
format!(