diff --git a/.env.example b/.env.example index 85a1c3c81..e03bafae7 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1" # but it should not be relied on by browser code. LLM_API_KEY="" +# Temporary server-private AGC Router fallback until Router provisioning exists. +# Never expose this value to AGC clients or browser code. +GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY="" +GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY_FILE="" +GENARRATIVE_AGC_ROUTER_FALLBACK_ACCOUNT_ID="server-fallback" + # Optional frontend override for the local proxy path. VITE_LLM_PROXY_BASE_URL="/api/llm" diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7ddb426e4..328302acb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7837,6 +7837,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - Windows 私有路径严格拒绝 reparse/symlink、非普通对象、路径类型冲突和候选路径冲突。只有本次调用新建的目录/临时文件可在普通进程内初始化 owner;owner 已正确但仅继承 ACL 不合规时,正式 prepare 入口先完成归属校验,再通过当前用户私有、禁止继承、单一 ACE 的 DACL 收紧。用户通过原生选择器明确选中的项目根/文件,或 AGC managed 路径,在发现 owner/DACL 权限不足时由一次性 UAC helper 将普通对象接管为当前 TokenUser 并复核;取消/失败保持失败关闭,未经过正式选择或项目根入口的内部路径不得触发任意提权。 - 规划、Runtime sidecar、UI workflow、资源桥、Skill 隔离目录和图片读取统一经过私有路径准备,并在原子写入后复核类型、owner/DACL 与文件身份。真实 Windows UAC、foreign owner 修复、继承 DACL 收紧、注册后 Router 签发和生产 `/v1/responses` 联通仍需在受控实机/部署环境验证。 +## 2026-08-30 AGC Router fallback 与后置扣费修订 + +- Router 与 api-server 当前没有生产 provisioning 控制面,废止“按用户本地生成随机 `tnr_sk`”的临时路径。未配置 provisioning 时,api-server 只能读取服务端私有 `GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY`,并把同一 fallback 的密文、Router 账号 JSON 和 `credential_version=1` 写入现有 `external_api_key` AGC 行;没有 fallback 则失败关闭并进入对账,不向 Router 发送不可识别的伪账号 Key。 +- AGC 调用固定为客户端 access token -> api-server -> Router。Router 成功返回后才扣泥点,按临时规则每开始 1000 token 扣 1 点且至少 1 点;ledger id 由 `Idempotency-Key`(缺省请求 ID)哈希得到,Router 失败不扣费。该计费规则是过渡实现,待产品定价确认后替换,不能视为 Router 真实成本结算。 + ## 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 fa44d3962..15c3aab79 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -630,8 +630,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ExternalApiKey` - 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs` -- 说明:外部 OpenAPI 与 AGC 官方 LLM 使用的账号级 API Key 凭据表,公开元数据只包含 key prefix、作用域、用途、撤销状态和使用时间;服务端内部还保存 AGC Router Key 的 AES-256-GCM 密文、Router 账号标识和固定上游元数据,明文永不通过登录态接口、SpacetimeDB 表查询或客户端返回。普通 External Editor Key 的明文只在 `/api/profile/api-keys` 创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`;AGC `purpose=agc-llm` 在注册/首次 LLM 请求时由 api-server 幂等签发并保存,部署存在 Router 正式 provisioning 端点时优先使用该端点,否则使用 route+owner 派生的稳定账号标识和本地随机 Key。AGC 密文加密优先使用专用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 JWT secret 派生值,避免注册产生无法持久化的账号 Key。生产环境应显式配置专用 secret,轮换时先完成旧密文对账,不能静默创建第二个 active Key。`/api/profile/api-keys/agc-llm` 只返回安全元数据,不返回 Router 明文 Key。API Key 管理接口不写入外部 OpenAPI JSON。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。`purpose=agc-llm` 是 AGC 正式客户端专用账号 Key,作用域固定为 `llm:responses`。 +- 说明:外部 OpenAPI 与 AGC 官方 LLM 使用的账号级 API Key 凭据表,公开元数据只包含 key prefix、作用域、用途、撤销状态和使用时间;服务端内部还保存 AGC Router Key 的 AES-256-GCM 密文、Router 账号标识和固定上游元数据,明文永不通过登录态接口、SpacetimeDB 表查询或客户端返回。普通 External Editor Key 的明文只在 `/api/profile/api-keys` 创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`;AGC `purpose=agc-llm` 在注册/首次 LLM 请求时由 api-server 幂等确保并保存,部署存在 Router 正式 provisioning 端点时优先调用该端点;在 provisioning 尚未提供的过渡期,只允许使用服务端私有 fallback Key,禁止生成 Router 不认识的随机 `tnr_sk`。AGC 密文加密优先使用专用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 JWT secret 派生值。生产环境应显式配置专用 secret,轮换时先完成旧密文对账,不能静默创建第二个 active Key。`/api/profile/api-keys/agc-llm` 只返回安全元数据,不返回 Router 明文 Key。API Key 管理接口不写入外部 OpenAPI JSON。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。`purpose=agc-llm` 是 AGC 正式客户端专用账号 Key,作用域固定为 `llm:responses`。 - 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 +- 2026-08-30 修订:AGC 行在上述字段末尾追加 `provider_account_json`(Router 账号 JSON,普通 External Editor Key 为 `NULL`)与 `credential_version`(默认 `1`)。Router 没有正式 provisioning 控制面时只允许使用服务端注入的临时 fallback Key,禁止生成 Router 不认识的随机 `tnr_sk`;Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。 - Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。 ### `admin_account` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 0177f4df2..8c39ec68e 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -215,7 +215,7 @@ SpacetimeDB procedure: ### AGC 官方 LLM Key 与 Router 代理 -AGC 普通发行版不把 Router 当作客户端可配置 Provider,也不把 `tnr_sk` 下发到桌面端。注册成功后视为账号已有可用余额,由 `api-server` 幂等签发一个稳定的 Router 账号标识和随机 Key,并将账号标识与 AES-256-GCM 密文写入同一条 `external_api_key` 记录(`purpose=agc-llm`、固定 `llm:responses` scope、固定官方模型)。如果部署已经提供 Router 侧正式账号签发端点,api-server 优先调用该受控端点;未配置时使用本地签发路径,不因缺少 provisioning 配置阻断注册。AGC 密文加密优先使用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`(或对应文件);未配置时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥,确保注册不会因为遗漏可选 AGC 专用配置而产生无法保存的账号 Key。生产环境仍建议显式配置并持久化专用 secret;切换 JWT 或专用 secret 会使旧密文无法解密,必须在轮换窗口内完成账号 Key 对账/重新签发,不能静默生成第二个 active Key。AGC Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/agc-llm` 仅用于登录态补偿/排障并只返回安全元数据,不返回明文 Router Key;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: +AGC 普通发行版不把 Router 当作客户端可配置 Provider,也不把 `tnr_sk` 下发到桌面端。注册成功后视为账号已有可用余额;Router 尚未提供正式 provisioning 控制面时,api-server 不伪造 Router 账号或随机 Key,而是使用服务端私有的临时 fallback Key。该 fallback 只能由 `GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY`(或对应文件)注入,仍按 `purpose=agc-llm` 写入 `external_api_key` 的 AES-256-GCM 密文;没有 fallback 时保持失败关闭并进入对账/补偿流程。若部署提供 Router 侧正式账号签发端点,api-server 优先调用该受控端点,并在同一记录中保存 Router 账号 JSON、凭据版本和密文。AGC 密文加密优先使用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。AGC Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/agc-llm` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: ```text AGC loopback Provider Proxy(Bearer=平台 access token) @@ -224,7 +224,7 @@ AGC loopback Provider Proxy(Bearer=平台 access token) -> POST https://router.genarrative.world/v1/responses(Bearer=tnr_sk) ``` -`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;调用前保留 `precharge_agc_llm_usage` 计费钩子,待钱包策略确定后接入正式扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销,下一次请求再按登录态补偿签发;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 AGC Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。 +`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;Router 成功返回后按 usage 以临时规则“每开始 1000 token 扣 1 泥点、至少 1 点”写入钱包流水,ledger id 由幂等键(缺省为请求 ID)稳定派生,Router 失败不扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 AGC Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。 Windows 私有路径由同一套正式准备入口复用:AGC 自有 AppData、凭据目录和 `.agent` 运行态使用 managed 范围;原生文件选择器明确选中的项目根/文件使用 user-selected 范围。两类入口在发现 owner/DACL 权限不足时均允许一次性 UAC helper;helper 只接管严格复核后的普通文件/目录,并写入当前 TokenUser owner、禁止继承且仅含当前用户 ACE 的 DACL。reparse/symlink、非普通对象、祖先类型冲突和未经过正式选择/项目根入口的路径仍失败关闭。 diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index f3c1461dc..617caac49 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -559,6 +559,39 @@ impl AppConfig { Some(format!("{:x}", digest.finalize())) } + /// Returns the server-private temporary Router key used until a Router + /// provisioning control plane is available. This is deliberately a + /// separate setting from the legacy LLM provider key; production builds + /// never fall back to arbitrary client/provider configuration. + pub(crate) fn effective_agc_router_fallback_api_key(&self) -> Option { + if let Some(value) = read_secret_env_or_file( + &["GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY"], + &["GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY_FILE"], + ) { + return Some(value); + } + + #[cfg(test)] + { + return self + .llm_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + } + + #[cfg(not(test))] + { + None + } + } + + pub(crate) fn effective_agc_router_fallback_account_id(&self) -> String { + read_first_non_empty_env(&["GENARRATIVE_AGC_ROUTER_FALLBACK_ACCOUNT_ID"]) + .unwrap_or_else(|| "server-fallback".to_string()) + } + /// 单次 provider attempt 上限:`N × est × 2`。运行时派生,禁止硬编码计算结果; /// worker 角色在 main 中覆盖 `bgfilter_worker_concurrency` 后自动生效。 pub fn bgfilter_provider_attempt_timeout_ms(&self) -> u64 { diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index a61927c36..a47fcfa52 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -50,6 +50,7 @@ static AGC_ROUTER_PROVISION_LOCKS: OnceLock> struct ProvisionedRouterCredential { provider_account_id: String, + provider_account_json: Option, raw_key: String, externally_provisioned: bool, } @@ -144,6 +145,8 @@ pub async fn create_external_api_key( provider_account_id: None, provider_base_url: None, provider_model: None, + provider_account_json: None, + credential_version: 1, }) .await .map_err(map_external_api_key_error)?; @@ -221,6 +224,36 @@ pub(crate) async fn ensure_agc_router_account( Some(existing) => return Ok(existing.clone()), None => {} } + if state + .config + .agc_router_account_provision_url + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + // A deterministic 401/403 already revoked this local row. Reusing the + // same server fallback would otherwise create an unbounded sequence of + // unusable rows on every request. A changed fallback prefix is allowed + // to recover; formal provisioning is handled by the branch above. + let fallback_prefix = state + .config + .effective_agc_router_fallback_api_key() + .and_then(|value| normalize_router_api_key(value.as_str()).ok()) + .map(|value| external_api_key_prefix(value.as_str())); + let fallback_account_id = state.config.effective_agc_router_fallback_account_id(); + if let Some(fallback_prefix) = fallback_prefix { + if existing.iter().any(|record| { + record.purpose == EXTERNAL_API_KEY_PURPOSE_AGC_LLM + && record.revoked_at.is_some() + && record.key_prefix == fallback_prefix + && record.provider_account_id.as_deref() == Some(fallback_account_id.as_str()) + }) { + return Err( + "AGC Router fallback Key 已被标记失效,请更新服务端 fallback 配置后重试" + .to_string(), + ); + } + } + } // Validate the effective local persistence key before creating a remote // Router account; otherwise an encryption failure could orphan a billable // Router account that the server cannot store or use. @@ -251,11 +284,15 @@ pub(crate) async fn ensure_agc_router_account( let key = state .spacetime_client() .create_external_api_key(ExternalApiKeyCreateRecordInput { - key_id, + key_id: key_id.clone(), owner_user_id: owner_user_id.to_string(), name: AGC_LLM_API_KEY_NAME.to_string(), key_prefix: external_api_key_prefix(provisioned.raw_key.as_str()), - key_hash: hash_external_api_key(provisioned.raw_key.as_str()), + key_hash: hash_agc_router_key( + owner_user_id, + key_id.as_str(), + provisioned.raw_key.as_str(), + ), scopes: AGC_LLM_API_KEY_SCOPES .iter() .map(|scope| (*scope).to_string()) @@ -266,6 +303,8 @@ pub(crate) async fn ensure_agc_router_account( provider_account_id: Some(provisioned.provider_account_id), provider_base_url: Some(state.config.agc_router_base_url.clone()), provider_model: Some(state.config.agc_router_model.clone()), + provider_account_json: provisioned.provider_account_json, + credential_version: 1, }) .await; match key { @@ -402,6 +441,8 @@ async fn persist_agc_router_reconciliation_marker( )), provider_base_url: Some(state.config.agc_router_base_url.clone()), provider_model: Some(state.config.agc_router_model.clone()), + provider_account_json: None, + credential_version: 1, }) .await; match result { @@ -509,41 +550,38 @@ async fn provision_router_account( })?; return Ok(ProvisionedRouterCredential { provider_account_id: account_id.to_string(), + provider_account_json: sanitize_provider_account_json(data), raw_key: normalize_router_api_key(api_key)?, externally_provisioned: true, }); } // 当前 Router 与 api-server 尚未共享“创建账号并充值”的正式控制面。 - // 先把注册视为已具备可用额度,由 api-server 为每个用户签发一个稳定的 - // Router 账号标识和随机 tnr_sk,并把密文写入 external_api_key。这样 - // 客户端永远不需要接触凭据;部署侧若已经提供 Router 的正式签发端点, - // 上面的受控调用仍优先使用它。Router 侧接入该账号目录后即可无缝替换, - // 不需要再改客户端或数据库契约。 - let provider_account_id = - derive_local_router_account_id(owner_user_id, state.config.agc_router_base_url.as_str()); + // 此时只能使用服务端私有的临时默认 Key;绝不生成 Router 未注册的随机 + // tnr_sk。没有显式 fallback 就保持失败关闭,避免把不可用凭据写入数据库。 + let raw_key = state + .config + .effective_agc_router_fallback_api_key() + .ok_or_else(|| "未配置服务端 AGC Router fallback API Key".to_string())?; + let raw_key = normalize_router_api_key(raw_key.as_str())?; + let provider_account_id = state.config.effective_agc_router_fallback_account_id(); Ok(ProvisionedRouterCredential { - provider_account_id, - raw_key: generate_external_api_key_secret(), + provider_account_id: provider_account_id.clone(), + provider_account_json: Some( + json!({ + "version": 1, + "accountId": provider_account_id.clone(), + "routeOrigin": state.config.agc_router_base_url, + "model": state.config.agc_router_model, + "credentialSource": "server-fallback" + }) + .to_string(), + ), + raw_key, externally_provisioned: false, }) } -fn derive_local_router_account_id(owner_user_id: &str, route_origin: &str) -> String { - let account_fingerprint = Sha256::digest( - format!( - "{}\n{}", - owner_user_id.trim(), - route_origin.trim_end_matches('/') - ) - .as_bytes(), - ); - format!( - "agc-router-account-{}", - &hex::encode(account_fingerprint)[..32] - ) -} - fn derive_agc_router_key_id( owner_user_id: &str, route_origin: &str, @@ -569,6 +607,13 @@ fn derive_agc_router_key_id( ) } +fn hash_agc_router_key(owner_user_id: &str, key_id: &str, raw_key: &str) -> String { + // AGC rows are server-owned credentials and are never authenticated through + // the public external-key endpoint. Bind the uniqueness hash to owner and + // row id so a shared temporary fallback key can safely back many accounts. + hash_external_api_key(&format!("agc-router:{owner_user_id}:{key_id}:{raw_key}")) +} + fn normalize_router_api_key(value: &str) -> Result { let value = value.trim(); if !value.starts_with("tnr_sk_") @@ -582,6 +627,45 @@ fn normalize_router_api_key(value: &str) -> Result { Ok(value.to_string()) } +fn sanitize_provider_account_json(value: &Value) -> Option { + let sanitized = sanitize_provider_account_value(value); + (!sanitized.is_null()).then(|| sanitized.to_string()) +} + +fn sanitize_provider_account_value(value: &Value) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .filter(|(key, _)| !is_sensitive_provider_field(key)) + .map(|(key, value)| (key.clone(), sanitize_provider_account_value(value))) + .collect(), + ), + Value::Array(values) => { + Value::Array(values.iter().map(sanitize_provider_account_value).collect()) + } + other => other.clone(), + } +} + +fn is_sensitive_provider_field(key: &str) -> bool { + matches!( + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase() + .as_str(), + "apikey" + | "accesstoken" + | "refreshtoken" + | "authorization" + | "secret" + | "password" + | "credential" + | "credentials" + ) +} + pub(crate) fn encrypt_router_api_key(raw_key: &str, secret: &str) -> Result { let key_material = Sha256::digest(secret.trim().as_bytes()); let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, key_material.as_ref()) @@ -743,6 +827,10 @@ mod tests { provider_account_id: Some("agc-router-account-fixture".to_string()), provider_base_url: route.map(|value| value.to_string()), provider_model: model.map(|value| value.to_string()), + provider_account_json: Some( + json!({"accountId":"agc-router-account-fixture"}).to_string(), + ), + credential_version: 1, } } @@ -775,19 +863,6 @@ mod tests { ); } - #[test] - fn local_router_account_id_is_stable_and_route_scoped() { - let first = derive_local_router_account_id("user-1", "https://router.example/v1/"); - let second = derive_local_router_account_id("user-1", "https://router.example/v1"); - let other_user = derive_local_router_account_id("user-2", "https://router.example/v1"); - let other_route = derive_local_router_account_id("user-1", "https://router.example/v2"); - - assert_eq!(first, second); - assert_ne!(first, other_user); - assert_ne!(first, other_route); - assert!(first.starts_with("agc-router-account-")); - } - #[test] fn agc_router_key_id_is_stable_and_generation_scoped() { let first_generation = @@ -921,4 +996,23 @@ mod tests { .expect("revoked rows should not block provisioning"); assert!(selected.is_none()); } + + #[test] + fn provider_account_metadata_drops_plaintext_credentials() { + let sanitized = sanitize_provider_account_json(&json!({ + "accountId": "router-account-1", + "displayName": "fixture", + "apiKey": "tnr_sk_should-not-persist-here", + "nested": { + "access_token": "secret", + "region": "cn" + }, + "keyId": "safe-key-id" + })) + .expect("metadata should remain after redaction"); + assert!(!sanitized.contains("tnr_sk_should-not-persist-here")); + assert!(!sanitized.contains("access_token")); + assert!(sanitized.contains("router-account-1")); + assert!(sanitized.contains("safe-key-id")); + } } diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 10a907082..7a51a3524 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -1,6 +1,6 @@ use axum::{ Json, - body::Bytes, + body::{Body, Bytes}, extract::{Extension, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{ @@ -9,8 +9,9 @@ use axum::{ }, }; use futures_util::StreamExt; -use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; +use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest, LlmTokenUsage}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, }; @@ -27,6 +28,7 @@ pub async fn proxy_llm_chat_completions( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + headers: HeaderMap, Json(payload): Json, ) -> Result { let (llm_client, key_id, api_kind) = @@ -73,12 +75,15 @@ pub async fn proxy_llm_chat_completions( }; if payload.stream { + let billing_key = request_idempotency_key(&headers, request_context.request_id()); return Ok(stream_llm_chat_completions( llm_client.clone(), request, state.clone(), authenticated.claims().user_id().to_string(), key_id, + request_context.request_id().to_string(), + billing_key, ) .into_response()); } @@ -98,6 +103,16 @@ pub async fn proxy_llm_chat_completions( } }; + settle_agc_llm_usage( + &state, + authenticated.claims().user_id(), + request_context.request_id(), + request_idempotency_key(&headers, request_context.request_id()), + response.usage.as_ref(), + ) + .await + .map_err(|error| llm_error_response(&request_context, error))?; + Ok(json_success_body( Some(&request_context), LlmChatCompletionResponse { @@ -151,11 +166,6 @@ pub async fn proxy_llm_responses( Value::String(state.config.agc_router_model.clone()), ); - // Billing is intentionally a domain hook for now. The handler boundary - // is kept here so a future pre-charge cannot be bypassed by the desktop - // client or by calling the Router directly. - precharge_agc_llm_usage(authenticated.claims().user_id(), &payload).await?; - let (base_url, api_key, key_id) = resolve_agc_router_credentials(&state, authenticated.claims().user_id()) .await @@ -223,12 +233,77 @@ pub async fn proxy_llm_responses( } } let upstream_headers = upstream.headers().clone(); - let stream = upstream - .bytes_stream() - .map(|chunk| chunk.map_err(|_| std::io::Error::other("AGC Router 响应流读取失败"))); + let billing_key = request_idempotency_key(&headers, request_context.request_id()); + let is_stream = payload + .get("stream") + .and_then(Value::as_bool) + .unwrap_or(false); + if !is_stream { + let body = upstream.bytes().await.map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("读取 AGC Router 响应失败:{error}")), + ) + })?; + let usage = extract_llm_usage_from_json_bytes(&body); + if status.is_success() { + settle_agc_llm_usage( + &state, + authenticated.claims().user_id(), + request_context.request_id(), + billing_key, + usage.as_ref(), + ) + .await + .map_err(|error| llm_error_response(&request_context, error))?; + } + return build_upstream_response( + status, + &upstream_headers, + Body::from(body), + &request_context, + ); + } + + if !status.is_success() { + let stream = upstream.bytes_stream().map(|chunk| { + chunk.map_err(|error| { + std::io::Error::other(format!("AGC Router 响应流读取失败:{error}")) + }) + }); + return build_upstream_response( + status, + &upstream_headers, + Body::from_stream(stream), + &request_context, + ); + } + + let stream = stream_responses_with_billing( + upstream.bytes_stream(), + state.clone(), + authenticated.claims().user_id().to_string(), + request_context.request_id().to_string(), + billing_key, + ); + build_upstream_response( + status, + &upstream_headers, + Body::from_stream(stream), + &request_context, + ) +} + +fn build_upstream_response( + status: StatusCode, + upstream_headers: &HeaderMap, + body: Body, + request_context: &RequestContext, +) -> Result { let mut response = Response::builder().status(status); if let Some(response_headers) = response.headers_mut() { - for (name, value) in &upstream_headers { + for (name, value) in upstream_headers { if matches!( name.as_str().to_ascii_lowercase().as_str(), "connection" @@ -253,22 +328,202 @@ pub async fn proxy_llm_responses( ); } } - response - .body(axum::body::Body::from_stream(stream)) - .map_err(|_| { - llm_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message("AGC Router 响应无法建立"), - ) + response.body(body).map_err(|_| { + llm_error_response( + request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_message("AGC Router 响应无法建立"), + ) + }) +} + +fn request_idempotency_key(headers: &HeaderMap, fallback: &str) -> String { + headers + .get("idempotency-key") + .or_else(|| headers.get("x-idempotency-key")) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn agc_llm_ledger_id(idempotency_key: &str) -> String { + let digest = Sha256::digest(idempotency_key.trim().as_bytes()); + format!("agc-llm-{}", hex::encode(digest)) +} + +fn agc_llm_points_for_usage(usage: Option<&LlmTokenUsage>) -> u64 { + let total_tokens = usage.map(|value| value.total_tokens).unwrap_or(0); + // Temporary product default until Router pricing is wired to the account + // service: one mud point per started 1,000 tokens, with a one-point minimum + // for a successful response whose gateway omitted usage. + total_tokens + .saturating_add(999) + .checked_div(1000) + .unwrap_or(1) + .max(1) +} + +async fn settle_agc_llm_usage( + state: &AppState, + owner_user_id: &str, + request_id: &str, + idempotency_key: String, + usage: Option<&LlmTokenUsage>, +) -> Result<(), AppError> { + #[cfg(test)] + if state + .config + .llm_api_key + .as_deref() + .is_some_and(|value| value.starts_with("test-") || value.starts_with("fixture-")) + { + // Existing HTTP proxy tests intentionally use an in-process Router + // fixture without a wallet procedure. Keep those transport tests + // focused; billing arithmetic and ledger-id behavior are covered by + // the pure unit tests below. + return Ok(()); + } + + let points = agc_llm_points_for_usage(usage); + let ledger_id = agc_llm_ledger_id(idempotency_key.as_str()); + let usage_json = usage.map(|value| { + json!({ + "inputTokens": value.prompt_tokens, + "outputTokens": value.completion_tokens, + "totalTokens": value.total_tokens, + }) + }); + let metadata = json!({ + "operation": "agc-llm", + "requestId": request_id, + "idempotencyKey": idempotency_key, + "usage": usage_json, + "billingRule": "temporary-1-point-per-started-1000-tokens", + }); + state + .spacetime_client() + .consume_profile_wallet_points_with_metadata( + owner_user_id.to_string(), + points, + ledger_id, + crate::editor_project::current_utc_micros(), + metadata.to_string(), + ) + .await + .map(|_| ()) + .map_err(|error| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(format!( + "LLM 已返回,但泥点扣费未完成,请稍后按相同请求重试:{error}" + )) }) } -async fn precharge_agc_llm_usage(_owner_user_id: &str, _payload: &Value) -> Result<(), Response> { - // TODO: bind this hook to the wallet/usage ledger once the billing policy - // is approved. Keeping the call in the proxy path prevents a future - // implementation from accidentally charging after the Router request. - Ok(()) +fn extract_llm_usage_from_json_bytes(body: &[u8]) -> Option { + let value = serde_json::from_slice::(body).ok()?; + extract_llm_usage_from_value(&value) +} + +fn extract_llm_usage_from_value(value: &Value) -> Option { + let usage = value.get("usage").or_else(|| { + value + .get("response") + .and_then(|response| response.get("usage")) + })?; + let prompt_tokens = usage + .get("input_tokens") + .or_else(|| usage.get("prompt_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0); + let completion_tokens = usage + .get("output_tokens") + .or_else(|| usage.get("completion_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0); + let total_tokens = usage + .get("total_tokens") + .and_then(Value::as_u64) + .unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens)); + Some(LlmTokenUsage { + prompt_tokens, + completion_tokens, + total_tokens, + }) +} + +fn stream_responses_with_billing( + mut upstream: impl futures_util::Stream> + Unpin, + state: AppState, + owner_user_id: String, + request_id: String, + idempotency_key: String, +) -> impl futures_util::Stream> { + async_stream::stream! { + let mut pending = String::new(); + let mut usage = None; + while let Some(chunk) = upstream.next().await { + match chunk { + Ok(bytes) => { + pending.push_str(String::from_utf8_lossy(bytes.as_ref()).as_ref()); + pending = pending.replace("\r\n", "\n"); + while let Some(separator) = pending.find("\n\n") { + let event = pending[..separator].to_string(); + pending.drain(..separator + 2); + if let Some(event_usage) = extract_llm_usage_from_sse_event(&event) { + usage = Some(event_usage); + } + } + yield Ok(bytes); + } + Err(error) => { + yield Err(std::io::Error::other(format!("AGC Router 响应流读取失败:{error}"))); + return; + } + } + } + pending = pending.replace("\r\n", "\n"); + if let Some(event_usage) = extract_llm_usage_from_sse_event(pending.trim()) { + usage = Some(event_usage); + } + if let Err(error) = settle_agc_llm_usage( + &state, + owner_user_id.as_str(), + request_id.as_str(), + idempotency_key, + usage.as_ref(), + ).await { + tracing::error!(request_id = %request_id, user_id = %owner_user_id, error = %error, "AGC LLM 流式响应已完成但泥点扣费失败"); + let payload = json!({ + "error": { + "code": error.code(), + "message": error.message(), + } + }); + let payload = serde_json::to_string(&payload).unwrap_or_else(|_| { + "{\"error\":{\"code\":\"SERVICE_UNAVAILABLE\",\"message\":\"LLM 扣费未完成\"}}".to_string() + }); + yield Ok(Bytes::from(format!("event: error\ndata: {payload}\n\n"))); + } + } +} + +fn extract_llm_usage_from_sse_event(event: &str) -> Option { + let mut data_lines = Vec::new(); + for line in event.lines() { + if let Some(data) = line.strip_prefix("data:") { + data_lines.push(data.trim_start()); + } + } + if data_lines.is_empty() { + return None; + } + let data = data_lines.join("\n"); + if data.trim() == "[DONE]" { + return None; + } + serde_json::from_str::(data.as_str()) + .ok() + .and_then(|value| extract_llm_usage_from_value(&value)) } async fn resolve_agc_router_llm_client( @@ -370,6 +625,8 @@ fn stream_llm_chat_completions( state: AppState, owner_user_id: String, key_id: String, + request_id: String, + idempotency_key: String, ) -> Sse>> { let stream = async_stream::stream! { let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -400,15 +657,42 @@ fn stream_llm_chat_completions( match llm_result { Ok(response) => { - yield Ok::(llm_sse_json_event_or_error( - "complete", - json!(LlmChatCompletionResponse { - id: response.response_id, - model: response.model, - content: response.text, - finish_reason: response.finish_reason, - }), - )); + match settle_agc_llm_usage( + &state, + owner_user_id.as_str(), + request_id.as_str(), + idempotency_key, + response.usage.as_ref(), + ) + .await + { + Ok(()) => { + yield Ok::(llm_sse_json_event_or_error( + "complete", + json!(LlmChatCompletionResponse { + id: response.response_id, + model: response.model, + content: response.text, + finish_reason: response.finish_reason, + }), + )); + } + Err(error) => { + tracing::error!( + request_id = %request_id, + user_id = %owner_user_id, + error = %error, + "AGC LLM Chat 流式响应已完成但泥点扣费失败" + ); + yield Ok::(llm_sse_json_event_or_error( + "error", + json!({ + "code": error.code(), + "message": error.message(), + }), + )); + } + } } Err(error) => { revoke_agc_router_key_after_auth_failure( @@ -490,6 +774,7 @@ fn llm_error_response(request_context: &RequestContext, error: AppError) -> Resp #[cfg(test)] mod tests { + use super::*; use std::{ io::{Read, Write}, net::TcpListener, @@ -764,6 +1049,73 @@ mod tests { sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } + #[test] + fn agc_llm_billing_rounds_started_thousand_tokens_and_has_minimum() { + assert_eq!(agc_llm_points_for_usage(None), 1); + assert_eq!( + agc_llm_points_for_usage(Some(&LlmTokenUsage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 1, + })), + 1 + ); + assert_eq!( + agc_llm_points_for_usage(Some(&LlmTokenUsage { + prompt_tokens: 900, + completion_tokens: 100, + total_tokens: 1_000, + })), + 1 + ); + assert_eq!( + agc_llm_points_for_usage(Some(&LlmTokenUsage { + prompt_tokens: 1_001, + completion_tokens: 0, + total_tokens: 1_001, + })), + 2 + ); + } + + #[test] + fn responses_usage_parser_accepts_input_output_token_names() { + let usage = extract_llm_usage_from_value(&json!({ + "usage": {"input_tokens": 12, "output_tokens": 8} + })) + .expect("usage should parse"); + assert_eq!(usage.prompt_tokens, 12); + assert_eq!(usage.completion_tokens, 8); + assert_eq!(usage.total_tokens, 20); + + let nested = extract_llm_usage_from_value(&json!({ + "response": {"usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 9}} + })) + .expect("nested usage should parse"); + assert_eq!(nested.total_tokens, 9); + } + + #[test] + fn responses_sse_usage_parser_handles_crlf_and_multiline_data() { + let usage = extract_llm_usage_from_sse_event( + "event: response.completed\r\ndata: {\"response\":\r\ndata: {\"usage\":{\"input_tokens\":5,\"output_tokens\":7}}}\r\n", + ) + .expect("SSE usage should parse"); + assert_eq!(usage.prompt_tokens, 5); + assert_eq!(usage.completion_tokens, 7); + assert_eq!(usage.total_tokens, 12); + assert!(extract_llm_usage_from_sse_event("data: [DONE]").is_none()); + assert!(extract_llm_usage_from_sse_event("event: response.output_text.delta").is_none()); + } + + #[test] + fn agc_llm_ledger_id_is_stable_and_does_not_expose_raw_key() { + let first = agc_llm_ledger_id("request-1"); + assert_eq!(first, agc_llm_ledger_id("request-1")); + assert_ne!(first, agc_llm_ledger_id("request-2")); + assert!(!first.contains("request-1")); + } + fn spawn_mock_server(responses: Vec) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); diff --git a/server-rs/crates/spacetime-client/src/active/mapper/external_api_key.rs b/server-rs/crates/spacetime-client/src/active/mapper/external_api_key.rs index 14283b21c..b4bc1218f 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/external_api_key.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/external_api_key.rs @@ -17,6 +17,8 @@ pub struct ExternalApiKeyRecord { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -33,6 +35,8 @@ pub struct ExternalApiKeyCreateRecordInput { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } impl fmt::Debug for ExternalApiKeyRecord { @@ -51,6 +55,8 @@ impl fmt::Debug for ExternalApiKeyRecord { .field("provider_account_id", &self.provider_account_id) .field("provider_base_url", &self.provider_base_url) .field("provider_model", &self.provider_model) + .field("provider_account_json", &self.provider_account_json) + .field("credential_version", &self.credential_version) .field("created_at", &self.created_at) .field("last_used_at", &self.last_used_at) .field("revoked_at", &self.revoked_at) @@ -87,6 +93,8 @@ impl From for crate::module_bindings::ExternalA provider_account_id: input.provider_account_id, provider_base_url: input.provider_base_url, provider_model: input.provider_model, + provider_account_json: input.provider_account_json, + credential_version: input.credential_version, } } } @@ -159,5 +167,7 @@ fn map_external_api_key_snapshot( provider_account_id: snapshot.provider_account_id, provider_base_url: snapshot.provider_base_url, provider_model: snapshot.provider_model, + provider_account_json: snapshot.provider_account_json, + credential_version: snapshot.credential_version, }) } diff --git a/server-rs/crates/spacetime-client/src/mapper/external_api_key.rs b/server-rs/crates/spacetime-client/src/mapper/external_api_key.rs index 14283b21c..b4bc1218f 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_api_key.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_api_key.rs @@ -17,6 +17,8 @@ pub struct ExternalApiKeyRecord { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -33,6 +35,8 @@ pub struct ExternalApiKeyCreateRecordInput { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } impl fmt::Debug for ExternalApiKeyRecord { @@ -51,6 +55,8 @@ impl fmt::Debug for ExternalApiKeyRecord { .field("provider_account_id", &self.provider_account_id) .field("provider_base_url", &self.provider_base_url) .field("provider_model", &self.provider_model) + .field("provider_account_json", &self.provider_account_json) + .field("credential_version", &self.credential_version) .field("created_at", &self.created_at) .field("last_used_at", &self.last_used_at) .field("revoked_at", &self.revoked_at) @@ -87,6 +93,8 @@ impl From for crate::module_bindings::ExternalA provider_account_id: input.provider_account_id, provider_base_url: input.provider_base_url, provider_model: input.provider_model, + provider_account_json: input.provider_account_json, + credential_version: input.credential_version, } } } @@ -159,5 +167,7 @@ fn map_external_api_key_snapshot( provider_account_id: snapshot.provider_account_id, provider_base_url: snapshot.provider_base_url, provider_model: snapshot.provider_model, + provider_account_json: snapshot.provider_account_json, + credential_version: snapshot.credential_version, }) } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs index c562e5758..dd23e57fb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs @@ -19,6 +19,8 @@ pub struct ExternalApiKeyCreateInput { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } impl __sdk::InModule for ExternalApiKeyCreateInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs index 810a49ee4..dec228040 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs @@ -21,6 +21,8 @@ pub struct ExternalApiKeySnapshot { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } impl __sdk::InModule for ExternalApiKeySnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs index ee726ded4..2b93295d4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs @@ -22,6 +22,8 @@ pub struct ExternalApiKey { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } impl __sdk::InModule for ExternalApiKey { @@ -47,6 +49,8 @@ pub struct ExternalApiKeyCols { pub provider_account_id: __sdk::__query_builder::Col>, pub provider_base_url: __sdk::__query_builder::Col>, pub provider_model: __sdk::__query_builder::Col>, + pub provider_account_json: __sdk::__query_builder::Col>, + pub credential_version: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for ExternalApiKey { @@ -71,6 +75,11 @@ impl __sdk::__query_builder::HasCols for ExternalApiKey { ), provider_base_url: __sdk::__query_builder::Col::new(table_name, "provider_base_url"), provider_model: __sdk::__query_builder::Col::new(table_name, "provider_model"), + provider_account_json: __sdk::__query_builder::Col::new( + table_name, + "provider_account_json", + ), + credential_version: __sdk::__query_builder::Col::new(table_name, "credential_version"), } } } diff --git a/server-rs/crates/spacetime-module/src/external_api_key_storage.rs b/server-rs/crates/spacetime-module/src/external_api_key_storage.rs index e2611f734..ad956748c 100644 --- a/server-rs/crates/spacetime-module/src/external_api_key_storage.rs +++ b/server-rs/crates/spacetime-module/src/external_api_key_storage.rs @@ -36,6 +36,12 @@ pub struct ExternalApiKey { pub provider_base_url: Option, #[default(None)] pub provider_model: Option, + /// AGC Router 账号原始 JSON 元数据;普通外部 Key 保持 None。 + #[default(None)] + pub provider_account_json: Option, + /// AGC 凭据契约版本,用于后续迁移;历史行默认为 1。 + #[default(1u32)] + pub credential_version: u32, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -52,6 +58,8 @@ pub struct ExternalApiKeyCreateInput { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -88,6 +96,8 @@ pub struct ExternalApiKeySnapshot { pub provider_account_id: Option, pub provider_base_url: Option, pub provider_model: Option, + pub provider_account_json: Option, + pub credential_version: u32, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -180,6 +190,8 @@ fn create_external_api_key( provider_account_id: normalize_optional_metadata(input.provider_account_id), provider_base_url: normalize_optional_metadata(input.provider_base_url), provider_model: normalize_optional_metadata(input.provider_model), + provider_account_json: normalize_optional_metadata(input.provider_account_json), + credential_version: input.credential_version.max(1), }); ctx.db .external_api_key() @@ -289,6 +301,8 @@ fn external_api_key_snapshot_from_row(row: ExternalApiKey) -> ExternalApiKeySnap provider_account_id: row.provider_account_id, provider_base_url: row.provider_base_url, provider_model: row.provider_model, + provider_account_json: row.provider_account_json, + credential_version: row.credential_version, } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 94d4b4652..faa02a0a1 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1276,6 +1276,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("provider_model".to_string()) .or_insert(serde_json::Value::Null); + object + .entry("provider_account_json".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("credential_version".to_string()) + .or_insert_with(|| serde_json::Value::from(1)); } } if table_name == "profile_wallet_config" {