diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a8c42f052..6b09a8776 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 结果回传。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index bf59b9699..5317141a8 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 7550efcc2..a762c5a0a 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -43,12 +43,20 @@ static TEST_PROVISIONED_ROUTER_CREDENTIALS: OnceLock< Mutex>, > = OnceLock::new(); +#[cfg(test)] +static TEST_LLM_ROUTER_WALLET_BALANCES: OnceLock>> = OnceLock::new(); + #[cfg(test)] fn test_provisioned_router_credentials() -> &'static Mutex> { TEST_PROVISIONED_ROUTER_CREDENTIALS.get_or_init(|| Mutex::new(HashMap::new())) } +#[cfg(test)] +fn test_llm_router_wallet_balances() -> &'static Mutex> { + TEST_LLM_ROUTER_WALLET_BALANCES.get_or_init(|| Mutex::new(HashMap::new())) +} + pub async fn proxy_llm_chat_completions( State(state): State, Extension(request_context): Extension, @@ -56,6 +64,12 @@ pub async fn proxy_llm_chat_completions( headers: HeaderMap, Json(payload): Json, ) -> Result { + 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::(&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!(