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
3 changed files with 129 additions and 0 deletions
Showing only changes of commit a30f03a38c - Show all commits
@@ -7918,6 +7918,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 检查锚点固定为认证后的账号准备、显式 Router Key 准备和 LLM 请求解析凭据路径,不放入 Responses 流式 chunk;同一 api-server 进程继续复用 owner 级 provisioning mutex,跨实例重复订阅幂等性依赖 Router 端后续约束或部署侧串行化。
- 订阅查询/创建只使用 api-server 私有管理员 Token;管理员 Token 缺失时保留启动 Warning 并跳过续期检查,不能伪造客户端凭据或把 Router Key 暴露给客户端。
## 2026-09-02 LLM Router 零泥点前置门禁
- AGC LLM 对话入口在解析 Router 凭据和访问上游前先读取用户 `wallet_balance`。余额为 `0` 时直接返回 `409 MUD_POINTS_INSUFFICIENT`,客户端显示“泥点余额不足”;不创建、续期或使用 Router 账号。余额读取失败同样失败关闭,返回“泥点余额暂时不可用”。
- 余额大于 `0` 的请求继续走 Router,成功后仍按 best-effort 后置结算;退款占用、冻结或扣费时余额不足的处理继续由钱包事务和既有结算规则负责。
## 2026-08-29 DirectProject 受控联网搜索默认与边界
- 正式产品本次只覆盖 `DirectProject` 单 Codex Agent。`Provider``ToolHost``DirectHome` 不是 Agent,也不是本次联网主链路;不新增全路由联网或工具桥。唯一受控联网工具为 `agc_tools.agc_web_search`,链路固定为 Codex MCP 工具目录 -> 客户端 loopback `DirectToolBridge` -> 有界 Bing RSS HTTPS -> 过滤 / 脱敏 -> MCP 结果回传。
@@ -636,6 +636,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 2026-09-01 修订:Router provisioning 允许所有环境使用官方固定 Router 控制面,以便独立开发数据库通过完整 owner `user_id` 的稳定派生凭据和 `agc_auto_generate` Token 复用同一远端账号/Key。由于 New API 的 `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` 保留权威映射。非官方公网地址仍拒绝,loopback 仅用于本地 fixture。使用共享官方 Router 的非生产环境启动时告警,提醒会触及线上账号与额度。api-server 不再提供任何 fallback Key 路径;没有已完成 provisioning 的账号行时,LLM 请求必须在本地解析阶段失败关闭。
- 2026-09-01 修订:每次账号认证、Router Key 准备或 LLM 请求解析既有账号时,api-server 都会在同一 owner 级 provisioning 锁内检查固定套餐 `plan_id=1`。没有 active 订阅、订阅已过期或 `end_time` 距当前 Unix 秒不超过 24 小时时,使用管理员接口新建一条订阅;超过 24 小时则复用现有订阅。订阅检查不进入 Responses 流式 chunk,管理员 Token 缺失时仅保留启动告警并跳过续期检查。
- 2026-09-02 修订:LLM Router 成功返回后按“每开始 10,000 token 扣 1 点、至少 1 点”后置结算。余额足够时全额扣除;余额不足时在同一钱包事务内扣除当前可消费余额并记录 `waivedPoints` 差额,响应仍正常返回。该规则为过渡产品策略,待真实定价接入后替换。
- 2026-09-02 修订:`/api/llm/responses``/api/llm/chat/completions` 在解析 Router 凭据和发起上游请求前先读取用户 `wallet_balance`;余额为 `0` 时直接返回 `409 MUD_POINTS_INSUFFICIENT`,不创建、续期或使用 Router 账号。余额读取失败同样失败关闭,返回泥点余额暂不可用。
- Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。
### `admin_account`
+123
View File
@@ -43,12 +43,20 @@ static TEST_PROVISIONED_ROUTER_CREDENTIALS: OnceLock<
Mutex<HashMap<String, TestProvisionedRouterCredential>>,
> = OnceLock::new();
#[cfg(test)]
static TEST_LLM_ROUTER_WALLET_BALANCES: OnceLock<Mutex<HashMap<String, u64>>> = OnceLock::new();
#[cfg(test)]
fn test_provisioned_router_credentials()
-> &'static Mutex<HashMap<String, TestProvisionedRouterCredential>> {
TEST_PROVISIONED_ROUTER_CREDENTIALS.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg(test)]
fn test_llm_router_wallet_balances() -> &'static Mutex<HashMap<String, u64>> {
TEST_LLM_ROUTER_WALLET_BALANCES.get_or_init(|| Mutex::new(HashMap::new()))
}
pub async fn proxy_llm_chat_completions(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -56,6 +64,12 @@ pub async fn proxy_llm_chat_completions(
headers: HeaderMap,
Json(payload): Json<LlmChatCompletionRequest>,
) -> Result<Response, Response> {
if let Err(error) =
ensure_llm_router_user_can_start_conversation(&state, authenticated.claims().user_id())
.await
{
return Err(llm_error_response(&request_context, error));
}
let (llm_client, key_id) =
match resolve_llm_router_client(&state, authenticated.claims().user_id()).await {
Ok((client, key_id)) => (client, key_id),
@@ -167,6 +181,13 @@ pub async fn proxy_llm_responses(
));
}
if let Err(error) =
ensure_llm_router_user_can_start_conversation(&state, authenticated.claims().user_id())
.await
{
return Err(llm_error_response(&request_context, error));
}
let mut payload = serde_json::from_slice::<Value>(&body).map_err(|_| {
llm_error_response(
&request_context,
@@ -386,6 +407,61 @@ fn build_upstream_response(
})
}
async fn ensure_llm_router_user_can_start_conversation(
state: &AppState,
owner_user_id: &str,
) -> Result<(), AppError> {
#[cfg(test)]
if let Some(wallet_balance) = test_llm_router_wallet_balances()
.lock()
.expect("test LLM wallet fixture lock should not poison")
.get(owner_user_id)
.copied()
{
return if wallet_balance == 0 {
Err(insufficient_mud_points_error())
} else {
Ok(())
};
}
#[cfg(test)]
if test_provisioned_router_credentials()
.lock()
.expect("test Router credential fixture lock should not poison")
.contains_key(owner_user_id)
{
return Ok(());
}
let dashboard = state
.spacetime_client()
.get_profile_dashboard(owner_user_id.to_string())
.await
.map_err(|error| {
tracing::warn!(
user_id = %owner_user_id,
error = %error,
"读取用户泥点余额失败,已拒绝发起 LLM 对话"
);
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("泥点余额暂时不可用,请稍后重试")
})?;
if dashboard.wallet_balance == 0 {
return Err(insufficient_mud_points_error());
}
Ok(())
}
fn insufficient_mud_points_error() -> AppError {
AppError::from_status(StatusCode::CONFLICT)
.with_code("MUD_POINTS_INSUFFICIENT")
.with_message("泥点余额不足")
.with_details(json!({
"reason": "insufficient-mud-points",
}))
}
fn request_idempotency_key(headers: &HeaderMap, fallback: &str) -> String {
headers
.get("idempotency-key")
@@ -1343,6 +1419,53 @@ mod tests {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn llm_responses_rejects_zero_wallet_before_router_provisioning() {
let (state, user_id) = seed_authenticated_state(AppConfig {
llm_router_base_url: "https://127.0.0.1:1/v1".to_string(),
llm_router_api_key_encryption_secret: Some("fixture-encryption-secret".to_string()),
..AppConfig::default()
})
.await;
test_llm_router_wallet_balances()
.lock()
.expect("test LLM wallet fixture lock should not poison")
.insert(user_id.clone(), 0);
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("content-type", "application/json")
.body(Body::from(json!({"input":"hello"}).to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["code"],
Value::String("MUD_POINTS_INSUFFICIENT".to_string())
);
assert_eq!(
payload["error"]["message"],
Value::String("泥点余额不足".to_string())
);
}
async fn seed_authenticated_state(config: AppConfig) -> (AppState, String) {
let state = AppState::new(config).expect("state should build");
let phone = format!(