From 736a1b6ac63712518293ae86f92fbc7822c8d933 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 6 Sep 2026 00:36:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20LLM=20Router=20=E7=B4=AF?= =?UTF-8?q?=E8=AE=A1=E9=A2=9D=E5=BA=A6=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Router used_quota 累计值与首次基线结算泥点 新增原子 checkpoint 事务及 llm_router_consume 钱包流水 同步额度查询校验、前端展示、生成绑定和技术文档 --- docs/README.md | 2 + .../shared-memory/decision-log.md | 2 +- .../【技术方案】LLM累计额度结算-2026-09-05.md | 35 ++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 13 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 4 +- .../index.test.tsx | 1 + .../PlatformProfileWalletLedgerModal/model.ts | 1 + packages/shared/src/contracts/runtime.ts | 1 + server-rs/crates/api-server/src/admin.rs | 12 +- .../api-server/src/external_api_keys.rs | 2 +- server-rs/crates/api-server/src/llm/mod.rs | 552 ++++-------------- .../crates/api-server/src/runtime_profile.rs | 9 + .../crates/module-runtime/src/application.rs | 35 -- server-rs/crates/module-runtime/src/domain.rs | 2 + server-rs/crates/module-runtime/src/lib.rs | 15 + .../crates/module-runtime/src/llm_billing.rs | 86 +++ server-rs/crates/platform-llm/src/lib.rs | 1 + .../crates/platform-llm/src/router_billing.rs | 226 +++++++ .../crates/shared-contracts/src/runtime.rs | 1 + .../src/active/mapper/runtime_profile.rs | 3 + .../src/llm_router_account.rs | 49 ++ .../spacetime-client/src/module_bindings.rs | 34 ++ .../llm_router_billing_checkpoint_table.rs | 235 ++++++++ .../llm_router_billing_checkpoint_type.rs | 58 ++ .../llm_router_quota_settlement_input_type.rs | 18 + ...llm_router_quota_settlement_result_type.rs | 19 + ..._profile_wallet_ledger_source_type_type.rs | 2 + ...e_llm_router_quota_and_return_procedure.rs | 59 ++ .../crates/spacetime-module/src/migration.rs | 1 + .../src/runtime/active/profile.rs | 272 +++++---- .../platformProfileFundsModel.ts | 1 + 31 files changed, 1171 insertions(+), 580 deletions(-) create mode 100644 docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md create mode 100644 server-rs/crates/module-runtime/src/llm_billing.rs create mode 100644 server-rs/crates/platform-llm/src/router_billing.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/settle_llm_router_quota_and_return_procedure.rs diff --git a/docs/README.md b/docs/README.md index d44e466ad..7453573fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,8 @@ ## AI 游戏创作与 Agent Runtime +- [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 + - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f307d796b..00fe2141a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7997,7 +7997,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 - 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `llm_router_account` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。 -- AGC 调用固定为客户端 access token -> api-server -> Router。Router 成功返回后再扣泥点,按临时规则每开始 10,000 token 扣 1 点且至少 1 点;ledger id 由 `Idempotency-Key`(缺省请求 ID)哈希得到,Router 失败不扣费。余额足够时全额扣除;余额不足时按当前可消费余额扣光,差额记为赠送并继续返回已成功的 LLM 响应。该计费规则是过渡实现,待产品定价确认后替换,不能视为 Router 真实成本结算。 +- AGC 调用固定为客户端 access token -> api-server -> Router。计费读取账号 `used_quota`,每 50000 quota 扣 1 泥点,美元数值乘 10、不乘汇率。首次模型调用前以当前累计额度完整建立免追扣基线,之后调用前后同步;扣钱包、写 `llm_router_consume` 流水与推进已结算额度同事务完成。小数和余额不足未支付部分继续累计,失败或重复同步不推进已结算额度,不使用本地 WAL 或余数队列。完整合同见 `docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md`。 - AGC 状态面收口:Tauri `check_game_creator_llm_config`、`/llm-status` 与 `/llm-routes` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 ## 2026-09-01 LLM Router provisioning 环境隔离与测试门禁 diff --git a/docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md b/docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md new file mode 100644 index 000000000..8ee540cdc --- /dev/null +++ b/docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md @@ -0,0 +1,35 @@ +# LLM 累计额度结算 + +## 目标与边界 + +使用 New API `GET /api/user/{id}` 的 `used_quota` 累计值结算,不按 token 估价,不依赖单次响应 cost,不建立本地 WAL、余数 Map 或延迟队列。模型调用前同步并检查余额,成功响应后再同步;失败、断流或进程退出留下的消耗由下一次调用前同步补结算。不承诺无后续调用的闲置账号立即结清。 + +## 单位与算法 + +Router `GET /api/status` 的 `data.quota_per_unit` 已实测为 500000,即每美元 500000 quota。美元数值直接乘 10 转泥点,不使用 USDExchangeRate。每泥点对应 50000 quota,采用整数运算。查询时验证单位不变,异常时拒绝结算,不静默改价。 + +```text +pending = max(observed_used_quota - settled_quota, 0) +charged_points = min(pending / 50000, spendable_points) +next_settled_quota = settled_quota + charged_points * 50000 +``` + +`settled_quota` 只推进实际扣费对应的部分。查询失败、钱包被冻结、扣款失败时不推进。重复或乱序快照不回退游标;Router 重置累计值需人工核对,不自动清零本地记录。 + +## 持久化与事务 + +新增私有表 `llm_router_billing_checkpoint`,主键沿用已认证用户的 `llm_router_account.account_key`,保存 `router_user_id`、`settled_quota` 和更新时间。账号 owner/route 来自现有本地映射,不信任客户端提交的 Router ID;同一映射更换 Router ID 时拒绝结算,需明确迁移。 + +首次同步在模型请求之前执行:若没有 checkpoint,以当前累计额度完整建立基线,不扣历史,不抹零。现有账号与首次接入账号均在使用前建立基线。 + +只有服务身份可以调用结算 procedure。事务内先检查人工冻结与退款欠款限制(包括尚不足一整点与首次初始化),再读取 checkpoint,计算可扣金额,更新钱包和流水,最后推进 checkpoint;三者同一事务提交。返回的剩余可消费余额扣除了退款占用与本次消费,模型调用前据此拒绝零可用余额。并发、重复快照和响应丢失后重试不能重复扣费。流水 ID 由账号与已结算额度区间构成,而非单次请求 ID。 + +新增流水来源 `llm_router_consume`,显示“LLM 调用消耗”,保持免费/会员/永久泥点消耗顺序、退款冻结和消费统计。历史资产来源流水不改写;旧资产生成扣费/退款协议不变。 + +## 验收 + +- 首次基线、234 余量跨次保留、重复/乱序快照、余额不足、零余额、整数溢出均有定向测试。 +- Router 查询验证业务 success、用户 ID、非负整数 used_quota、quota_per_unit;错误不打印凭据或原始用户数据。 +- 模型请求前基线失败关闭;成功模型响应不因后置同步故障变为失败,下一次可重试。 +- schema 同步 migration、表目录、生成绑定,运行定向 Rust 测试、schema guard、编码检查、diff check。 +- 本次不部署、不迁移历史钱包流水、不修改外部 OpenAPI。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 0c8d92e4c..8cae77d2d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -633,11 +633,11 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs` - 说明:本表只承载普通外部 OpenAPI/MCP API Key。明文只在 `/api/profile/api-keys` 创建接口返回一次,服务端保存 `key_hash`、`key_prefix`、作用域和撤销状态;本需求不向该表增加 Router 字段,也不写入 Router 账号数据。v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`。 - 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 -- 2026-08-31 修订:Router 账号状态、API Key 核心字段、账号元数据和加密凭据统一写入 `llm_router_account`;`external_api_key` 保持普通外部 OpenAPI/MCP Key 的原有链路不变。公共 Router、独立数据库的部署必须共享同一版本 provisioning secret,数据库缺行时才能恢复同一远端账号。Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。 +- 2026-08-31 修订:Router 账号状态、API Key 核心字段、账号元数据和加密凭据统一写入 `llm_router_account`;`external_api_key` 保持普通外部 OpenAPI/MCP Key 的原有链路不变。公共 Router、独立数据库的部署必须共享同一版本 provisioning secret,数据库缺行时才能恢复同一远端账号。LLM 计费的当前权威口径是下方 2026-09-05 累计额度结算规则。 - 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 账号。余额读取失败同样失败关闭,返回泥点余额暂不可用。 +- LLM Router 计费:读取 Router 用户 `used_quota`,以 `50000 quota = 1 泥点` 结算(`500000 quota = 1 USD`,美元数值直接乘 10,不乘汇率)。上游调用前建立首次基线并补结算,成功响应后同步;不足整数部分、扣费失败和余额不足未支付部分留到后续累计同步。扣钱包、写 `llm_router_consume` 流水与推进 checkpoint 在同一事务完成;流水展示“LLM 调用消耗”。历史费用不追扣。详细契约见 `technical/【技术方案】LLM累计额度结算-2026-09-05.md`。 +- 2026-09-05 修订:`/api/llm/responses` 与 `/api/llm/chat/completions` 仍在 Router provisioning 前用钱包总余额阻止零余额账号创建或续期;解析凭据后、上游调用前再执行累计额度同步,以扣除退款占用后的剩余可消费余额为准。余额为 `0` 时返回 `409 MUD_POINTS_INSUFFICIENT`;余额或额度同步失败时失败关闭。上游已成功时,后置同步失败只记录错误并留待下次调用前补结算,不把成功模型响应改写为失败。 - Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。 ### `llm_router_account` @@ -649,6 +649,13 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 作用:记录用户与官方 LLM Router 账号的稳定映射、API Key 核心字段、账号生命周期、远端账号元数据、订阅检查状态、重试 / 对账状态和加密凭据版本。Router 明文 Key 只在 api-server 进程内短暂存在,数据库仅保存加密凭据;账号读取和写入统一通过对应 procedure 与 `spacetime-client` facade 完成。 - 索引:`by_llm_router_account_owner_route` 用于按 owner 与路由来源读取账号;`by_llm_router_account_status_next_retry` 用于按状态和下次重试时间扫描待对账账号。 +### `llm_router_billing_checkpoint` + +- Rust 结构体:`LlmRouterBillingCheckpoint` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` +- 私有表,主键 `account_key` 关联 `llm_router_account`,保存 `router_user_id`、`settled_quota`、`updated_at`。 +- `settle_llm_router_quota_and_return` 仅允许服务身份,验证 owner/route/Router ID;首次同步完整记录当前额度而不扣历史,后续仅按实际扣款推进额度,与钱包和流水同事务提交。旧快照不回退;账号更换拒绝自动重建。 + ### `agc_model_catalog` - 私有单例表,主键 `id=0`,保存 `catalog_json`、`revision`、`updated_at`;不存凭据。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 43208c976..fef7460d7 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -226,7 +226,7 @@ AGC loopback Provider Proxy(Bearer=平台 access token) -> POST https://router.genarrative.world/v1/responses(Bearer=) ``` -`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;Router 成功返回后按 usage 以临时规则“每开始 10,000 token 扣 1 泥点、至少 1 点”写入钱包流水,ledger id 由端点、客户端幂等键和请求指纹稳定派生,Router 失败不扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。AGC 的运行状态接口与 `/llm-status`、`/llm-routes` 只返回登录/账号凭据状态、官方路由锁定状态和运行参数,不返回 Router 地址、模型、协议名称或任何凭据字段。 +`/api/llm/responses` 透传 Responses JSON/SSE 响应;模型由当前 AGC 模型目录解析。计费读取 Router 子账号的累计 `used_quota`,按 `50000 quota = 1 泥点` 在调用前后同步;首次同步只建立历史基线,扣钱包、写 `llm_router_consume` 流水与推进 checkpoint 在同一事务完成。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。AGC 的运行状态接口与 `/llm-status`、`/llm-routes` 只返回登录/账号凭据状态、官方路由锁定状态和运行参数,不返回 Router 地址、模型、协议名称或任何凭据字段。 Windows 私有路径由同一套正式准备入口复用:AGC 自有 AppData、凭据目录和 `.agent` 运行态使用 managed 范围;原生文件选择器明确选中的项目根/文件使用 user-selected 范围。两类入口在发现 owner/DACL 权限不足时均允许一次性 UAC helper;helper 只接管严格复核后的普通文件/目录,并写入当前 TokenUser owner、禁止继承且仅含当前用户 ACE 的 DACL。reparse/symlink、非普通对象、祖先类型冲突和未经过正式选择/项目根入口的路径仍失败关闭。 @@ -293,4 +293,4 @@ docs/openapi/genarrative-external-v1.openapi.json - 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。 - 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。 - 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。 -> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 请求只使用本地已完成的账号密钥。LLM Router 计费按每开始 10,000 token 至少 1 点结算,客户端幂等键同时绑定端点与规范化请求指纹,禁止跨请求复用账本。账号密码派生根仅从部署侧受保护 secret/file 读取。 +> 2026-09-05 修订:认证成功后的 Router provisioning 为异步尽力修复,不阻塞主站登录;LLM 请求只使用本地已完成的账号密钥。LLM Router 计费改为累计 `used_quota` checkpoint 结算,不再依赖单次响应 usage、客户端幂等键或请求指纹;账号密码派生根仅从部署侧受保护 secret/file 读取。 diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx index 877ab5793..a2d15aea6 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -102,6 +102,7 @@ test('builds wallet ledger presentation with stable source fallbacks', () => { '资产操作消耗', ); expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source'); + expect(getWalletLedgerSourceLabel('llm_router_consume')).toBe('LLM 调用消耗'); expect(getWalletLedgerSourceLabel('')).toBe('未知来源'); expect(formatWalletLedgerDate('not-a-date')).toBe('not-a-date'); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index c39f623f4..40bd70e41 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -14,6 +14,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = { daily_free_grant: '每日免费发放', daily_free_reset: '每日免费重置', asset_operation_consume: '资产操作消耗', + llm_router_consume: 'LLM 调用消耗', asset_operation_refund: '资产操作退回', recharge_refund_recovery: '充值退款追回', redeem_code_reward: '兑换码奖励', diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 49cc54e22..1fa899e4d 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -77,6 +77,7 @@ export type ProfileWalletLedgerEntry = { | 'daily_free_grant' | 'daily_free_reset' | 'asset_operation_consume' + | 'llm_router_consume' | 'asset_operation_refund' | 'recharge_refund_recovery' | 'redeem_code_reward' diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index f7a61b70d..0fac037b9 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2707,7 +2707,7 @@ async fn fetch_admin_dashboard_wallet_stats( continue; } match source_type.as_str() { - "asset_operation_consume" if amount_delta < 0 => { + "asset_operation_consume" | "llm_router_consume" if amount_delta < 0 => { stats .consumed_mud_points .add(day_key, amount_delta.unsigned_abs()); @@ -4483,6 +4483,12 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option { 7 => "redeem_code_reward", 8 => "puzzle_author_incentive_claim", 9 => "daily_task_reward", + 10 => "membership_period_grant", + 11 => "membership_period_reset", + 12 => "daily_free_grant", + 13 => "daily_free_reset", + 14 => "recharge_refund_recovery", + 15 => "llm_router_consume", _ => return Some(Value::Array(items.to_vec()).to_string()), } .to_string(), @@ -5895,6 +5901,10 @@ mod tests { wallet_ledger_source_type_to_string(&json!("AssetOperationConsume")).as_deref(), Some("asset_operation_consume") ); + assert_eq!( + wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(), + Some("llm_router_consume") + ); } #[test] 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 1396c5560..30af7d894 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -897,7 +897,7 @@ async fn ensure_router_subscription( Ok(()) } -fn router_user_id_from_account(record: &LlmRouterAccountRecord) -> Option { +pub(crate) fn router_user_id_from_account(record: &LlmRouterAccountRecord) -> Option { record .router_account_id .as_deref() diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 75ae65de3..6170d04ad 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -9,9 +9,8 @@ use axum::{ }, }; use futures_util::StreamExt; -use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest, LlmTokenUsage}; +use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, LlmModelSummary, LlmModelsResponse, @@ -80,7 +79,6 @@ pub async fn proxy_llm_chat_completions( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - headers: HeaderMap, Json(payload): Json, ) -> Result { if let Err(error) = @@ -99,9 +97,11 @@ pub async fn proxy_llm_chat_completions( )); } }; + prepare_llm_router_billing(&state, authenticated.claims().user_id()) + .await + .map_err(|error| llm_error_response(&request_context, error))?; let api_kind = LlmApiKind::OpenAiResponses; - let request_fingerprint = serde_json::to_vec(&payload).unwrap_or_default(); let request = LlmRunRequest { model: None, api_kind, @@ -120,12 +120,6 @@ pub async fn proxy_llm_chat_completions( }; if payload.stream { - let billing_key = request_billing_key( - &headers, - request_context.request_id(), - "chat-completions", - request_fingerprint.as_slice(), - ); return Ok(stream_llm_chat_completions( llm_client.clone(), request, @@ -133,7 +127,6 @@ pub async fn proxy_llm_chat_completions( authenticated.claims().user_id().to_string(), key_id, request_context.request_id().to_string(), - billing_key, ) .into_response()); } @@ -153,28 +146,12 @@ pub async fn proxy_llm_chat_completions( } }; - if let Err(error) = settle_llm_router_usage( - &state, - authenticated.claims().user_id(), - request_context.request_id(), - request_billing_key( - &headers, - request_context.request_id(), - "chat-completions", - request_fingerprint.as_slice(), - ), - response.usage.as_ref(), - ) - .await - { - if is_mud_points_insufficient_app_error(&error) { - return Err(llm_error_response(&request_context, error)); - } + if let Err(error) = settle_llm_router_usage(&state, authenticated.claims().user_id()).await { tracing::error!( request_id = request_context.request_id(), user_id = %authenticated.claims().user_id(), error = %error, - "LLM Router 响应成功但泥点扣费未完成" + "LLM Router 响应成功但累计额度同步未完成" ); } @@ -339,6 +316,10 @@ pub async fn proxy_llm_responses( ) })?; + prepare_llm_router_billing(&state, authenticated.claims().user_id()) + .await + .map_err(|error| llm_error_response(&request_context, error))?; + let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(15)) .read_timeout(std::time::Duration::from_secs(180)) @@ -396,13 +377,6 @@ pub async fn proxy_llm_responses( } } let upstream_headers = upstream.headers().clone(); - let request_fingerprint = serde_json::to_vec(&payload).unwrap_or_default(); - let billing_key = request_billing_key( - &headers, - request_context.request_id(), - "responses", - request_fingerprint.as_slice(), - ); let is_stream = payload .get("stream") .and_then(Value::as_bool) @@ -415,25 +389,15 @@ pub async fn proxy_llm_responses( .with_message(format!("读取 LLM Router 响应失败:{error}")), ) })?; - let usage = extract_llm_usage_from_json_bytes(&body); if status.is_success() { - if let Err(error) = settle_llm_router_usage( - &state, - authenticated.claims().user_id(), - request_context.request_id(), - billing_key, - usage.as_ref(), - ) - .await + if let Err(error) = + settle_llm_router_usage(&state, authenticated.claims().user_id()).await { - if is_mud_points_insufficient_app_error(&error) { - return Err(llm_error_response(&request_context, error)); - } tracing::error!( request_id = request_context.request_id(), user_id = %authenticated.claims().user_id(), error = %error, - "LLM Router Responses 成功但泥点扣费未完成" + "LLM Router Responses 成功但累计额度同步未完成" ); } } @@ -464,7 +428,6 @@ pub async fn proxy_llm_responses( state.clone(), authenticated.claims().user_id().to_string(), request_context.request_id().to_string(), - billing_key, ); build_upstream_response( status, @@ -570,194 +533,94 @@ fn insufficient_mud_points_error() -> AppError { })) } -fn request_billing_key( - headers: &HeaderMap, - fallback: &str, - endpoint: &str, - request_bytes: &[u8], -) -> String { - let client_key = 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() - && value.len() <= 256 - && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) - }); - let Some(client_key) = client_key else { - return fallback.to_string(); - }; - let mut hasher = Sha256::new(); - hasher.update(b"llm-router-billing-key:v2\n"); - hasher.update(endpoint.as_bytes()); - hasher.update(b"\n"); - hasher.update(client_key.as_bytes()); - hasher.update(b"\n"); - hasher.update(request_bytes); - hex::encode(hasher.finalize()) -} - -fn llm_router_ledger_id(owner_user_id: &str, idempotency_key: &str) -> String { - let seed = format!( - "llm-router\n{}\n{}", - owner_user_id.trim(), - idempotency_key.trim() - ); - let digest = Sha256::digest(seed.as_bytes()); - // Reuse the existing wallet consume procedure contract. It accepts the - // asset-operation consume namespace and records the LLM Router operation in - // metadata, so no parallel billing reducer/table is introduced. - format!("asset_operation_consume:llm-router-{}", hex::encode(digest)) -} - -fn llm_router_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 10,000 tokens, with a one-point minimum - // for a successful response whose gateway omitted usage. Keep the unit in - // one constant so the product can tune it without changing the ledger - // semantics or idempotency contract. - const LLM_ROUTER_BILLING_TOKEN_UNIT: u64 = 10_000; - total_tokens - .saturating_add(LLM_ROUTER_BILLING_TOKEN_UNIT - 1) - .checked_div(LLM_ROUTER_BILLING_TOKEN_UNIT) - .unwrap_or(1) - .max(1) -} - -fn is_insufficient_mud_points_error(error: &SpacetimeClientError) -> bool { - match error { - SpacetimeClientError::Procedure(message) - | SpacetimeClientError::Runtime(message) - | SpacetimeClientError::Build(message) => { - message.contains("泥点余额不足") || message.contains("可消费泥点不足:") - } - SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => false, - } -} - -fn map_llm_router_billing_error(error: SpacetimeClientError) -> AppError { - if is_insufficient_mud_points_error(&error) { - return AppError::from_status(StatusCode::CONFLICT) - .with_code("MUD_POINTS_INSUFFICIENT") - .with_message("泥点余额不足") - .with_details(json!({ - "reason": "insufficient-mud-points", - })); - } - +fn map_llm_router_billing_error(_error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_code("LLM_BILLING_FAILED") - .with_message("LLM 已返回,但泥点扣费未完成") + .with_message("LLM 额度同步失败,请稍后重试") } -fn is_mud_points_insufficient_app_error(error: &AppError) -> bool { - error.code() == "MUD_POINTS_INSUFFICIENT" -} - -async fn settle_llm_router_usage( - state: &AppState, - owner_user_id: &str, - request_id: &str, - idempotency_key: String, - usage: Option<&LlmTokenUsage>, -) -> Result<(), AppError> { +async fn settle_llm_router_usage(state: &AppState, owner_user_id: &str) -> Result<(), AppError> { #[cfg(test)] if test_provisioned_router_credentials() .lock() .expect("test Router credential fixture lock should not poison") .contains_key(owner_user_id) { - // 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. + // Transport fixtures have no database; settlement arithmetic is tested in module-runtime. return Ok(()); } - let points = llm_router_points_for_usage(usage); - let ledger_id = llm_router_ledger_id(owner_user_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": "llm-router", - "billingMode": "llm-router-best-effort", - "requestedPoints": points, - "requestId": request_id, - "idempotencyKey": idempotency_key, - "usage": usage_json, - "billingRule": "temporary-1-point-per-started-10000-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(), - ) + sync_llm_router_quota(state, owner_user_id) .await .map(|_| ()) - .map_err(map_llm_router_billing_error) } -fn extract_llm_usage_from_json_bytes(body: &[u8]) -> Option { - let value = serde_json::from_slice::(body).ok()?; - extract_llm_usage_from_value(&value) +async fn prepare_llm_router_billing(state: &AppState, owner_user_id: &str) -> Result<(), AppError> { + #[cfg(test)] + if test_provisioned_router_credentials() + .lock() + .expect("test Router credential fixture lock should not poison") + .contains_key(owner_user_id) + { + return ensure_llm_router_user_can_start_conversation(state, owner_user_id).await; + } + if sync_llm_router_quota(state, owner_user_id).await? == 0 { + return Err(insufficient_mud_points_error()); + } + Ok(()) } -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 llm_router_insufficient_mud_points_sse_event() -> Bytes { - let error = json!({ - "code": "insufficient_mud_points", - "message": "泥点余额不足", - }); - let payload = json!({ - "type": "response.failed", - "error": error, - "response": { - "status": "failed", - "error": { - "code": "insufficient_mud_points", - "message": "泥点余额不足", - }, - }, - }); - Bytes::from(format!("event: response.failed\ndata: {}\n\n", payload)) +async fn sync_llm_router_quota(state: &AppState, owner_user_id: &str) -> Result { + let unavailable = || { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("LLM 额度同步失败,请稍后重试") + }; + let route = state.config.llm_router_base_url.trim_end_matches('/'); + let account = state + .spacetime_client() + .get_llm_router_account(owner_user_id.to_string(), route.to_string()) + .await + .map_err(|error| { + tracing::warn!(user_id = %owner_user_id, error = %error, "读取 LLM Router 账号映射失败"); + map_llm_router_billing_error(error) + })? + .ok_or_else(unavailable)?; + let user_id = + crate::external_api_keys::router_user_id_from_account(&account).ok_or_else(unavailable)?; + let token = state + .config + .llm_router_admin_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + .ok_or_else(unavailable)?; + let origin = route.strip_suffix("/v1").unwrap_or(route); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(5)) + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|_| unavailable())?; + let used_quota = + platform_llm::router_billing::read_router_used_quota(&client, origin, token, user_id) + .await + .map_err(|error| { + tracing::warn!(user_id = %owner_user_id, error = %error, "LLM 累计额度读取失败"); + unavailable() + })?; + state + .spacetime_client() + .settle_llm_router_quota( + owner_user_id.to_string(), + route.to_string(), + user_id, + used_quota, + ) + .await + .map(|result| result.spendable_points) + .map_err(|error| { + tracing::warn!(user_id = %owner_user_id, error = %error, "提交 LLM 累计额度结算失败"); + map_llm_router_billing_error(error) + }) } fn llm_router_done_sse_event() -> Bytes { @@ -779,16 +642,12 @@ fn finalize_llm_router_terminal_event( let billing_result = billing_result?; match billing_result { Ok(()) => terminal_event.take().map(Bytes::from), - Err(error) if is_mud_points_insufficient_app_error(error) => { - terminal_event.take(); - Some(llm_router_insufficient_mud_points_sse_event()) - } Err(error) => { tracing::error!( request_id = %request_id, user_id = %owner_user_id, error = %error, - "LLM Router 流式响应已完成但泥点扣费未完成" + "LLM Router 流式响应已完成但累计额度同步未完成" ); terminal_event.take().map(Bytes::from) } @@ -800,12 +659,10 @@ fn stream_responses_with_billing( 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 utf8_pending = Vec::new(); - let mut usage = None; let mut terminal_event = None; let mut completed_seen = false; let mut billing_result = None; @@ -821,9 +678,6 @@ fn stream_responses_with_billing( let raw_event = pending[..separator + 2].to_string(); 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); - } if is_responses_terminal_sse_event(&event) { completed_seen = true; terminal_event = Some(raw_event); @@ -831,9 +685,6 @@ fn stream_responses_with_billing( billing_result = Some(settle_llm_router_usage( &state, owner_user_id.as_str(), - request_id.as_str(), - idempotency_key.clone(), - usage.as_ref(), ).await); } continue; @@ -844,9 +695,6 @@ fn stream_responses_with_billing( billing_result = Some(settle_llm_router_usage( &state, owner_user_id.as_str(), - request_id.as_str(), - idempotency_key.clone(), - usage.as_ref(), ).await); } if terminal_event.is_some() { @@ -870,9 +718,6 @@ fn stream_responses_with_billing( billing_result = Some(settle_llm_router_usage( &state, owner_user_id.as_str(), - request_id.as_str(), - idempotency_key.clone(), - usage.as_ref(), ).await); } if terminal_event.is_some() { @@ -897,9 +742,6 @@ fn stream_responses_with_billing( pending = pending.replace("\r\n", "\n"); if !pending.trim().is_empty() { let event = pending.trim().to_string(); - if let Some(event_usage) = extract_llm_usage_from_sse_event(&event) { - usage = Some(event_usage); - } if is_responses_terminal_sse_event(&event) { completed_seen = true; terminal_event = Some(pending.clone()); @@ -915,9 +757,6 @@ fn stream_responses_with_billing( billing_result = Some(settle_llm_router_usage( &state, owner_user_id.as_str(), - request_id.as_str(), - idempotency_key, - usage.as_ref(), ).await); } else if !completed_seen { tracing::warn!( @@ -1024,25 +863,6 @@ fn sse_done_marker_suffix_len(bytes: &[u8]) -> usize { .unwrap_or(0) } -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_llm_router_client( state: &AppState, owner_user_id: &str, @@ -1132,7 +952,6 @@ fn stream_llm_chat_completions( 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::(); @@ -1166,48 +985,25 @@ fn stream_llm_chat_completions( if let Err(error) = settle_llm_router_usage( &state, owner_user_id.as_str(), - request_id.as_str(), - idempotency_key, - response.usage.as_ref(), ) .await { - if is_mud_points_insufficient_app_error(&error) { - yield Ok::(llm_sse_json_event_or_error( - "error", - json!({ - "code": "MUD_POINTS_INSUFFICIENT", - "message": "泥点余额不足", - }), - )); - } else { - tracing::error!( - request_id = %request_id, - user_id = %owner_user_id, - error = %error, - "LLM Router Chat 流式响应已完成但泥点扣费未完成" - ); - 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, - }), - )); - } - } else { - 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, - }), - )); + tracing::error!( + request_id = %request_id, + user_id = %owner_user_id, + error = %error, + "LLM Router Chat 流式响应已完成但累计额度同步未完成" + ); } + 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) => { revoke_llm_router_key_after_auth_failure( @@ -1693,109 +1489,26 @@ mod tests { } #[test] - fn llm_router_billing_rounds_started_ten_thousand_tokens_and_has_minimum() { - assert_eq!(llm_router_points_for_usage(None), 1); - assert_eq!( - llm_router_points_for_usage(Some(&LlmTokenUsage { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 1, - })), - 1 - ); - assert_eq!( - llm_router_points_for_usage(Some(&LlmTokenUsage { - prompt_tokens: 900, - completion_tokens: 100, - total_tokens: 10_000, - })), - 1 - ); - assert_eq!( - llm_router_points_for_usage(Some(&LlmTokenUsage { - prompt_tokens: 10_001, - completion_tokens: 0, - total_tokens: 10_001, - })), - 2 - ); - assert_eq!( - llm_router_points_for_usage(Some(&LlmTokenUsage { - prompt_tokens: 30_001, - completion_tokens: 0, - total_tokens: 30_001, - })), - 4 - ); - } - - #[test] - fn llm_router_billing_insufficient_balance_has_stable_public_error() { - let error = map_llm_router_billing_error(SpacetimeClientError::Procedure( - "可消费泥点不足:需要 31,扣除退款占用后可用 11".to_string(), - )); - assert_eq!(error.status_code(), StatusCode::CONFLICT); - assert_eq!(error.code(), "MUD_POINTS_INSUFFICIENT"); - assert_eq!(error.message(), "泥点余额不足"); - assert_eq!( - error - .details() - .and_then(|details| details.get("reason")) - .and_then(Value::as_str), - Some("insufficient-mud-points") - ); - } - - #[test] - fn llm_router_stream_terminal_is_replaced_only_for_insufficient_balance() { - let mut terminal = Some( - "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n".to_string(), - ); - let billing = Err(map_llm_router_billing_error( - SpacetimeClientError::Procedure("泥点余额不足".to_string()), - )); - let event = finalize_llm_router_terminal_event( - &mut terminal, - Some(&billing), - "request-1", - "user-1", - ) - .expect("insufficient balance should emit a failure event"); - let event_text = String::from_utf8(event.to_vec()).expect("failure event should be utf8"); - assert!(event_text.contains("response.failed")); - assert!(event_text.contains("insufficient_mud_points")); - assert!(!event_text.contains("response.completed")); - assert!(terminal.is_none()); - } - - #[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()); + fn llm_router_stream_preserves_success_on_any_settlement_failure() { + let original = "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"; + for billing in [ + Ok(()), + Err(insufficient_mud_points_error()), + Err(map_llm_router_billing_error( + SpacetimeClientError::ConnectDropped, + )), + ] { + let mut terminal = Some(original.to_string()); + let event = finalize_llm_router_terminal_event( + &mut terminal, + Some(&billing), + "request-1", + "user-1", + ) + .expect("completed output must remain available"); + assert_eq!(event.as_ref(), original.as_bytes()); + assert!(terminal.is_none()); + } } #[test] @@ -1810,35 +1523,6 @@ mod tests { assert_eq!(sse_done_marker_suffix_len(b"data: [DON"), 10); } - #[test] - fn llm_router_ledger_id_is_stable_and_does_not_expose_raw_key() { - let first = llm_router_ledger_id("user-1", "request-1"); - assert_eq!(first, llm_router_ledger_id("user-1", "request-1")); - assert_ne!(first, llm_router_ledger_id("user-1", "request-2")); - assert_ne!(first, llm_router_ledger_id("user-2", "request-1")); - assert!(first.starts_with("asset_operation_consume:llm-router-")); - assert!(!first.contains("request-1")); - } - - #[test] - fn request_billing_key_binds_client_key_to_endpoint_and_payload() { - let mut headers = HeaderMap::new(); - headers.insert("idempotency-key", HeaderValue::from_static("fixed")); - let first = request_billing_key(&headers, "request-1", "responses", br#"{"input":"a"}"#); - let replay = request_billing_key(&headers, "request-2", "responses", br#"{"input":"a"}"#); - let different_payload = - request_billing_key(&headers, "request-3", "responses", br#"{"input":"b"}"#); - let different_endpoint = request_billing_key( - &headers, - "request-4", - "chat-completions", - br#"{"input":"a"}"#, - ); - assert_eq!(first, replay); - assert_ne!(first, different_payload); - assert_ne!(first, different_endpoint); - } - #[test] fn append_utf8_chunk_preserves_multibyte_characters_split_across_chunks() { let mut text = String::new(); diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 67625908d..b7413f45a 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -190,6 +190,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME } + RuntimeProfileWalletLedgerSourceType::LlmRouterConsume => { + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_LLM_ROUTER_CONSUME + } RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND } @@ -2566,6 +2569,12 @@ mod tests { #[test] fn profile_wallet_ledger_source_type_formats_backend_values() { + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::LlmRouterConsume + ), + "llm_router_consume" + ); assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 5a855f99a..e956bf723 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -2124,21 +2124,6 @@ pub fn validate_runtime_profile_wallet_debit_availability( Ok(spendable_points.saturating_sub(debit_points)) } -/// LLM Router settles after a successful upstream response. It may consume -/// only the currently spendable portion of the wallet; any remainder of the -/// requested amount is intentionally waived by the product policy. -pub fn calculate_runtime_profile_wallet_best_effort_debit( - requested_points: i64, - wallet_total_points: u64, - active_held_points: u64, -) -> i64 { - requested_points.max(0).min( - wallet_total_points - .saturating_sub(active_held_points) - .min(i64::MAX as u64) as i64, - ) -} - pub fn build_runtime_profile_recharge_refund_settlement_plan( current_successful_refund_count: u32, current_cumulative_success_refund_cents: u64, @@ -2602,26 +2587,6 @@ fn parse_optional_json_value( mod tests { use super::*; - #[test] - fn llm_router_best_effort_debit_only_consumes_current_spendable_balance() { - assert_eq!( - calculate_runtime_profile_wallet_best_effort_debit(31, 100, 20), - 31 - ); - assert_eq!( - calculate_runtime_profile_wallet_best_effort_debit(31, 11, 0), - 11 - ); - assert_eq!( - calculate_runtime_profile_wallet_best_effort_debit(31, 31, 20), - 11 - ); - assert_eq!( - calculate_runtime_profile_wallet_best_effort_debit(31, 20, 20), - 0 - ); - } - #[test] fn feature_gate_denies_anonymous_when_enabled() { let gate = test_gate("creation-entry:puzzle"); diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 7a4a8c928..44148900c 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1151,6 +1151,7 @@ pub enum RuntimeProfileWalletLedgerSourceType { DailyFreeGrant, DailyFreeReset, RechargeRefundRecovery, + LlmRouterConsume, } impl RuntimeProfileWalletLedgerSourceType { @@ -1171,6 +1172,7 @@ impl RuntimeProfileWalletLedgerSourceType { Self::DailyFreeGrant => "daily_free_grant", Self::DailyFreeReset => "daily_free_reset", Self::RechargeRefundRecovery => "recharge_refund_recovery", + Self::LlmRouterConsume => "llm_router_consume", } } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index a7c8ede00..adc3b96e1 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1,4 +1,6 @@ mod agc_models; +mod llm_billing; +pub use llm_billing::*; mod application; pub use agc_models::*; mod commands; @@ -928,6 +930,10 @@ mod tests { RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery.as_str(), "recharge_refund_recovery" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::LlmRouterConsume.as_str(), + "llm_router_consume" + ); } #[test] @@ -1362,6 +1368,15 @@ mod tests { assert!( validate_runtime_profile_wallet_debit_restrictions(-1, consume, false, true).is_err() ); + assert!( + validate_runtime_profile_wallet_debit_restrictions( + -1, + RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, + true, + false, + ) + .is_err() + ); assert!( validate_runtime_profile_wallet_debit_restrictions( -1, diff --git a/server-rs/crates/module-runtime/src/llm_billing.rs b/server-rs/crates/module-runtime/src/llm_billing.rs new file mode 100644 index 000000000..037b3201d --- /dev/null +++ b/server-rs/crates/module-runtime/src/llm_billing.rs @@ -0,0 +1,86 @@ +/// Router quota per mud point, with USD numeric cost multiplied by ten. +pub const LLM_ROUTER_QUOTA_PER_POINT: u64 = 50_000; + +#[derive(Debug, PartialEq, Eq)] +pub struct LlmQuotaSettlement { + pub charged_points: u64, + pub settled_quota: u64, +} + +pub fn calculate_llm_quota_settlement( + baseline: Option, + observed: u64, + spendable_points: u64, +) -> LlmQuotaSettlement { + let Some(baseline) = baseline else { + return LlmQuotaSettlement { + charged_points: 0, + settled_quota: observed, + }; + }; + let points = (observed.saturating_sub(baseline) / LLM_ROUTER_QUOTA_PER_POINT) + .min(spendable_points) + .min(i64::MAX as u64); + LlmQuotaSettlement { + charged_points: points, + settled_quota: baseline + points * LLM_ROUTER_QUOTA_PER_POINT, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initializes_without_charging_history() { + assert_eq!( + calculate_llm_quota_settlement(None, 123_456, 100), + LlmQuotaSettlement { + charged_points: 0, + settled_quota: 123_456 + } + ); + } + + #[test] + fn carries_fraction_and_replays_without_double_charge() { + let first = calculate_llm_quota_settlement(Some(0), 61_700, 100); + assert_eq!(first.charged_points, 1); + assert_eq!(first.settled_quota, 50_000); + let next = calculate_llm_quota_settlement(Some(first.settled_quota), 111_700, 99); + assert_eq!(next.charged_points, 1); + assert_eq!(next.settled_quota, 100_000); + assert_eq!( + calculate_llm_quota_settlement(Some(next.settled_quota), 111_700, 98).charged_points, + 0 + ); + assert_eq!( + calculate_llm_quota_settlement(Some(next.settled_quota), 61_700, 98).settled_quota, + 100_000 + ); + } + + #[test] + fn insufficient_balance_only_advances_paid_quota() { + let first = calculate_llm_quota_settlement(Some(1234), 201_234, 1); + assert_eq!(first.settled_quota, 51_234); + assert_eq!( + calculate_llm_quota_settlement(Some(first.settled_quota), 201_234, 0).settled_quota, + 51_234 + ); + assert_eq!( + calculate_llm_quota_settlement(Some(first.settled_quota), 201_234, 10).charged_points, + 3 + ); + } + + #[test] + fn extreme_values_do_not_overflow() { + let result = calculate_llm_quota_settlement(Some(0), u64::MAX, u64::MAX); + assert!(result.settled_quota <= u64::MAX - u64::MAX % LLM_ROUTER_QUOTA_PER_POINT); + assert_eq!( + calculate_llm_quota_settlement(Some(u64::MAX), 0, u64::MAX).settled_quota, + u64::MAX + ); + } +} diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 38bea0e8b..6cd30f6a5 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use tokio::time::sleep; mod provider_adapter; +pub mod router_billing; pub use provider_adapter::{ ANTHROPIC_PROVIDER_INSTANCE_ID, ANTHROPIC_PROVIDER_PROTOCOL_ID, AnthropicProviderAdapter, diff --git a/server-rs/crates/platform-llm/src/router_billing.rs b/server-rs/crates/platform-llm/src/router_billing.rs new file mode 100644 index 000000000..312eda395 --- /dev/null +++ b/server-rs/crates/platform-llm/src/router_billing.rs @@ -0,0 +1,226 @@ +use reqwest::Client; +use serde_json::Value; + +/// Reads only accounting fields; never includes upstream bodies in errors. +pub async fn read_router_used_quota( + client: &Client, + origin: &str, + admin_token: &str, + user_id: i64, +) -> Result { + if user_id <= 0 { + return Err("Router 用户 ID 无效".into()); + } + if admin_token.trim().is_empty() { + return Err("Router 额度查询凭据缺失".into()); + } + let status = read_json(client.get(format!("{origin}/api/status"))).await?; + validate_router_quota_unit(&status)?; + let payload = read_json( + client + .get(format!("{origin}/api/user/{user_id}")) + .bearer_auth(admin_token), + ) + .await?; + parse_router_used_quota(&payload, user_id) +} + +fn validate_router_quota_unit(payload: &Value) -> Result<(), String> { + if payload.get("success").and_then(Value::as_bool) != Some(true) + || payload + .pointer("/data/quota_per_unit") + .and_then(Value::as_f64) + != Some(500_000.0) + { + return Err("Router 额度换算单位异常,已停止结算".into()); + } + Ok(()) +} + +async fn read_json(request: reqwest::RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|_| "Router 额度查询连接失败".to_string())?; + if !response.status().is_success() { + return Err(format!( + "Router 额度查询失败:HTTP {}", + response.status().as_u16() + )); + } + response + .json() + .await + .map_err(|_| "Router 额度查询响应无效".to_string()) +} + +fn parse_router_used_quota(payload: &Value, user_id: i64) -> Result { + if payload.get("success").and_then(Value::as_bool) != Some(true) + || payload.pointer("/data/id").and_then(Value::as_i64) != Some(user_id) + { + return Err("Router 额度查询失败或账号不匹配".into()); + } + payload + .pointer("/data/used_quota") + .and_then(Value::as_u64) + .ok_or_else(|| "Router 已用额度不是非负整数".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + use std::time::{Duration, Instant}; + + #[test] + fn validates_account_success_and_integer_quota() { + assert_eq!( + parse_router_used_quota( + &json!({"success":true,"data":{"id":21,"used_quota":90271}}), + 21 + ), + Ok(90271) + ); + for payload in [ + json!({"success":false,"data":{"id":21,"used_quota":0}}), + json!({"success":true,"data":{"id":22,"used_quota":0}}), + json!({"success":true,"data":{"id":21,"used_quota":-1}}), + json!({"success":true,"data":{"id":21,"used_quota":1.5}}), + json!({"success":true,"data":{"id":21}}), + ] { + assert!(parse_router_used_quota(&payload, 21).is_err()); + } + } + + #[test] + fn quota_unit_is_checked_without_using_exchange_rate() { + for unit in [json!(500_000), json!(500_000.0)] { + assert!( + validate_router_quota_unit(&json!({ + "success": true, + "data": {"quota_per_unit": unit, "usd_exchange_rate": 7.2} + })) + .is_ok() + ); + } + for payload in [ + json!({"success": false, "data": {"quota_per_unit": 500_000}}), + json!({"success": true, "data": {"quota_per_unit": 50_000}}), + json!({"success": true, "data": {"quota_per_unit": "500000"}}), + json!({"success": true, "data": {}}), + ] { + assert!(validate_router_quota_unit(&payload).is_err()); + } + } + + fn loopback_server(responses: Vec<(u16, String)>) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "missing Router request"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("{error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + } + requests.push(String::from_utf8(bytes).unwrap()); + write!(stream, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + } + requests + }); + (origin, handle) + } + + fn test_client() -> Client { + Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap() + } + + #[tokio::test] + async fn reads_unit_then_authenticated_account_over_http() { + let (origin, server) = loopback_server(vec![ + ( + 200, + json!({"success":true,"data":{"quota_per_unit":500000.0}}).to_string(), + ), + ( + 200, + json!({"success":true,"data":{"id":21,"used_quota":90271}}).to_string(), + ), + ]); + let result = read_router_used_quota(&test_client(), &origin, "test-admin", 21).await; + let requests = server.join().unwrap(); + assert_eq!(result, Ok(90271)); + assert!(requests[0].starts_with("GET /api/status ")); + assert!(!requests[0].contains("test-admin")); + assert!(requests[1].starts_with("GET /api/user/21 ")); + assert!(requests[1].contains("Bearer test-admin")); + } + + #[tokio::test] + async fn rejects_changed_unit_before_reading_account() { + let (origin, server) = loopback_server(vec![( + 200, + json!({"success":true,"data":{"quota_per_unit":100000}}).to_string(), + )]); + let result = read_router_used_quota(&test_client(), &origin, "test-admin", 21).await; + assert_eq!(server.join().unwrap().len(), 1); + assert!(result.unwrap_err().contains("换算单位异常")); + } + + #[tokio::test] + async fn http_errors_do_not_disclose_upstream_body_or_credentials() { + for status in [401, 403, 500] { + let (origin, server) = loopback_server(vec![ + ( + 200, + json!({"success":true,"data":{"quota_per_unit":500000}}).to_string(), + ), + (status, "private-account-body".into()), + ]); + let error = read_router_used_quota(&test_client(), &origin, "test-admin", 21) + .await + .unwrap_err(); + server.join().unwrap(); + assert!(error.contains(&status.to_string())); + assert!(!error.contains("private-account-body")); + assert!(!error.contains("test-admin")); + } + } + + #[tokio::test] + async fn rejects_invalid_account_or_missing_credential_without_network() { + let client = test_client(); + for (token, user_id) in [("test-admin", 0), ("test-admin", -1), (" ", 21)] { + let error = read_router_used_quota(&client, "http://127.0.0.1:1", token, user_id) + .await + .unwrap_err(); + assert!(!error.contains("连接失败")); + } + } +} diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 8172e3e97..d46843ff3 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -20,6 +20,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invit pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str = "asset_operation_consume"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_LLM_ROUTER_CONSUME: &str = "llm_router_consume"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str = diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 4573466b9..cc73d695d 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -46,6 +46,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::LlmRouterConsume => { + module_runtime::RuntimeProfileWalletLedgerSourceType::LlmRouterConsume + } crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund } diff --git a/server-rs/crates/spacetime-client/src/llm_router_account.rs b/server-rs/crates/spacetime-client/src/llm_router_account.rs index 613f45838..a0afb2d07 100644 --- a/server-rs/crates/spacetime-client/src/llm_router_account.rs +++ b/server-rs/crates/spacetime-client/src/llm_router_account.rs @@ -3,7 +3,56 @@ use crate::mapper::llm_router_account::{ map_llm_router_account_required_result, map_llm_router_account_result, }; +pub struct LlmRouterQuotaSettlementRecord { + pub settled_quota: u64, + pub charged_points: u64, + pub spendable_points: u64, +} + impl SpacetimeClient { + pub async fn settle_llm_router_quota( + &self, + owner_user_id: String, + route_origin: String, + router_user_id: i64, + used_quota: u64, + ) -> Result { + let input = crate::module_bindings::LlmRouterQuotaSettlementInput { + owner_user_id, + route_origin, + router_user_id, + used_quota, + }; + self.call_after_connect( + "settle_llm_router_quota_and_return", + move |connection, sender| { + connection + .procedures() + .settle_llm_router_quota_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(|result| { + if result.ok { + Ok(LlmRouterQuotaSettlementRecord { + settled_quota: result.settled_quota, + charged_points: result.charged_points, + spendable_points: result.spendable_points, + }) + } else { + Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "LLM 额度结算失败".to_string()), + )) + } + }); + send_once(&sender, mapped); + }); + }, + ) + .await + } + pub async fn get_llm_router_account( &self, owner_user_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b9bb70648..00999e3d6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -522,6 +522,10 @@ pub mod llm_router_account_snapshot_type; pub mod llm_router_account_table; pub mod llm_router_account_type; pub mod llm_router_account_upsert_input_type; +pub mod llm_router_billing_checkpoint_table; +pub mod llm_router_billing_checkpoint_type; +pub mod llm_router_quota_settlement_input_type; +pub mod llm_router_quota_settlement_result_type; pub mod mark_editor_showcase_asset_refunded_and_return_procedure; pub mod mark_profile_recharge_order_expiration_checked_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; @@ -845,6 +849,7 @@ pub mod save_editor_project_layout_v_2_ack_procedure; pub mod save_editor_project_layout_v_2_and_return_procedure; pub mod seed_analytics_date_dimensions_reducer; pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure; +pub mod settle_llm_router_quota_and_return_procedure; pub mod square_hole_agent_message_row_type; pub mod square_hole_agent_message_table; pub mod square_hole_agent_session_row_type; @@ -1426,6 +1431,10 @@ pub use llm_router_account_snapshot_type::LlmRouterAccountSnapshot; pub use llm_router_account_table::*; pub use llm_router_account_type::LlmRouterAccount; pub use llm_router_account_upsert_input_type::LlmRouterAccountUpsertInput; +pub use llm_router_billing_checkpoint_table::*; +pub use llm_router_billing_checkpoint_type::LlmRouterBillingCheckpoint; +pub use llm_router_quota_settlement_input_type::LlmRouterQuotaSettlementInput; +pub use llm_router_quota_settlement_result_type::LlmRouterQuotaSettlementResult; pub use mark_editor_showcase_asset_refunded_and_return_procedure::mark_editor_showcase_asset_refunded_and_return; pub use mark_profile_recharge_order_expiration_checked_procedure::mark_profile_recharge_order_expiration_checked; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; @@ -1749,6 +1758,7 @@ pub use save_editor_project_layout_v_2_ack_procedure::save_editor_project_layout pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project_layout_v_2_and_return; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return; +pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return; pub use square_hole_agent_message_row_type::SquareHoleAgentMessageRow; pub use square_hole_agent_message_table::*; pub use square_hole_agent_session_row_type::SquareHoleAgentSessionRow; @@ -1993,6 +2003,7 @@ pub struct DbUpdate { jump_hop_runtime_run: __sdk::TableUpdate, jump_hop_work_profile: __sdk::TableUpdate, llm_router_account: __sdk::TableUpdate, + llm_router_billing_checkpoint: __sdk::TableUpdate, match_3_d_agent_message: __sdk::TableUpdate, match_3_d_agent_session: __sdk::TableUpdate, match_3_d_runtime_run: __sdk::TableUpdate, @@ -2318,6 +2329,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "llm_router_account" => db_update .llm_router_account .append(llm_router_account_table::parse_table_update(table_update)?), + "llm_router_billing_checkpoint" => db_update.llm_router_billing_checkpoint.append( + llm_router_billing_checkpoint_table::parse_table_update(table_update)?, + ), "match_3_d_agent_message" => db_update.match_3_d_agent_message.append( match_3_d_agent_message_table::parse_table_update(table_update)?, ), @@ -2950,6 +2964,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.llm_router_account = cache .apply_diff_to_table::("llm_router_account", &self.llm_router_account) .with_updates_by_pk(|row| &row.account_key); + diff.llm_router_billing_checkpoint = cache + .apply_diff_to_table::( + "llm_router_billing_checkpoint", + &self.llm_router_billing_checkpoint, + ) + .with_updates_by_pk(|row| &row.account_key); diff.match_3_d_agent_message = cache .apply_diff_to_table::( "match_3_d_agent_message", @@ -3556,6 +3576,9 @@ impl __sdk::DbUpdate for DbUpdate { "llm_router_account" => db_update .llm_router_account .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "llm_router_billing_checkpoint" => db_update + .llm_router_billing_checkpoint + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "match_3_d_agent_message" => db_update .match_3_d_agent_message .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3998,6 +4021,9 @@ impl __sdk::DbUpdate for DbUpdate { "llm_router_account" => db_update .llm_router_account .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "llm_router_billing_checkpoint" => db_update + .llm_router_billing_checkpoint + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "match_3_d_agent_message" => db_update .match_3_d_agent_message .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4306,6 +4332,7 @@ pub struct AppliedDiff<'r> { jump_hop_runtime_run: __sdk::TableAppliedDiff<'r, JumpHopRuntimeRunRow>, jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>, llm_router_account: __sdk::TableAppliedDiff<'r, LlmRouterAccount>, + llm_router_billing_checkpoint: __sdk::TableAppliedDiff<'r, LlmRouterBillingCheckpoint>, match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>, match_3_d_agent_session: __sdk::TableAppliedDiff<'r, Match3DAgentSessionRow>, match_3_d_runtime_run: __sdk::TableAppliedDiff<'r, Match3DRuntimeRunRow>, @@ -4740,6 +4767,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.llm_router_account, event, ); + callbacks.invoke_table_row_callbacks::( + "llm_router_billing_checkpoint", + &self.llm_router_billing_checkpoint, + event, + ); callbacks.invoke_table_row_callbacks::( "match_3_d_agent_message", &self.match_3_d_agent_message, @@ -5823,6 +5855,7 @@ impl __sdk::SpacetimeModule for RemoteModule { jump_hop_runtime_run_table::register_table(client_cache); jump_hop_work_profile_table::register_table(client_cache); llm_router_account_table::register_table(client_cache); + llm_router_billing_checkpoint_table::register_table(client_cache); match_3_d_agent_message_table::register_table(client_cache); match_3_d_agent_session_table::register_table(client_cache); match_3_d_runtime_run_table::register_table(client_cache); @@ -5968,6 +6001,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "jump_hop_runtime_run", "jump_hop_work_profile", "llm_router_account", + "llm_router_billing_checkpoint", "match_3_d_agent_message", "match_3_d_agent_session", "match_3_d_runtime_run", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_table.rs new file mode 100644 index 000000000..c0b4b2535 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_table.rs @@ -0,0 +1,235 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::llm_router_billing_checkpoint_type::LlmRouterBillingCheckpoint; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `llm_router_billing_checkpoint`. +/// +/// Obtain a handle from the [`LlmRouterBillingCheckpointTableAccess::llm_router_billing_checkpoint`] method on [`super::RemoteTables`], +/// like `ctx.db.llm_router_billing_checkpoint()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.llm_router_billing_checkpoint().on_insert(...)`. +pub struct LlmRouterBillingCheckpointTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `llm_router_billing_checkpoint`. +pub struct LlmRouterBillingCheckpointTableAccessor; + +impl __sdk::TableAccessor for LlmRouterBillingCheckpointTableAccessor { + type Row = LlmRouterBillingCheckpoint; + type Handle<'db> = LlmRouterBillingCheckpointTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.llm_router_billing_checkpoint() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `llm_router_billing_checkpoint`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait LlmRouterBillingCheckpointTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`LlmRouterBillingCheckpointTableHandle`], which mediates access to the table `llm_router_billing_checkpoint`. + fn llm_router_billing_checkpoint(&self) -> LlmRouterBillingCheckpointTableHandle<'_>; +} + +impl LlmRouterBillingCheckpointTableAccess for super::RemoteTables { + fn llm_router_billing_checkpoint(&self) -> LlmRouterBillingCheckpointTableHandle<'_> { + LlmRouterBillingCheckpointTableHandle { + imp: self + .imp + .get_table::("llm_router_billing_checkpoint"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct LlmRouterBillingCheckpointInsertCallbackId(__sdk::CallbackId); +pub struct LlmRouterBillingCheckpointDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for LlmRouterBillingCheckpointTableHandle<'ctx> { + type Row = LlmRouterBillingCheckpoint; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for LlmRouterBillingCheckpointTableHandle<'ctx> { + type Row = LlmRouterBillingCheckpoint; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = LlmRouterBillingCheckpointInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> LlmRouterBillingCheckpointInsertCallbackId { + LlmRouterBillingCheckpointInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: LlmRouterBillingCheckpointInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = LlmRouterBillingCheckpointDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> LlmRouterBillingCheckpointDeleteCallbackId { + LlmRouterBillingCheckpointDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: LlmRouterBillingCheckpointDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for LlmRouterBillingCheckpointTableHandle<'ctx> { + type InsertCallbackId = LlmRouterBillingCheckpointInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> LlmRouterBillingCheckpointInsertCallbackId { + LlmRouterBillingCheckpointInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: LlmRouterBillingCheckpointInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for LlmRouterBillingCheckpointTableHandle<'ctx> { + type DeleteCallbackId = LlmRouterBillingCheckpointDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> LlmRouterBillingCheckpointDeleteCallbackId { + LlmRouterBillingCheckpointDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: LlmRouterBillingCheckpointDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct LlmRouterBillingCheckpointUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for LlmRouterBillingCheckpointTableHandle<'ctx> { + type UpdateCallbackId = LlmRouterBillingCheckpointUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> LlmRouterBillingCheckpointUpdateCallbackId { + LlmRouterBillingCheckpointUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: LlmRouterBillingCheckpointUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for LlmRouterBillingCheckpointTableHandle<'ctx> { + type UpdateCallbackId = LlmRouterBillingCheckpointUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> LlmRouterBillingCheckpointUpdateCallbackId { + LlmRouterBillingCheckpointUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: LlmRouterBillingCheckpointUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `account_key` unique index on the table `llm_router_billing_checkpoint`, +/// which allows point queries on the field of the same name +/// via the [`LlmRouterBillingCheckpointAccountKeyUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.llm_router_billing_checkpoint().account_key().find(...)`. +pub struct LlmRouterBillingCheckpointAccountKeyUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> LlmRouterBillingCheckpointTableHandle<'ctx> { + /// Get a handle on the `account_key` unique index on the table `llm_router_billing_checkpoint`. + pub fn account_key(&self) -> LlmRouterBillingCheckpointAccountKeyUnique<'ctx> { + LlmRouterBillingCheckpointAccountKeyUnique { + imp: self.imp.get_unique_constraint::("account_key"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> LlmRouterBillingCheckpointAccountKeyUnique<'ctx> { + /// Find the subscribed row whose `account_key` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("llm_router_billing_checkpoint"); + _table.add_unique_constraint::("account_key", |row| &row.account_key); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `LlmRouterBillingCheckpoint`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait llm_router_billing_checkpointQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `LlmRouterBillingCheckpoint`. + fn llm_router_billing_checkpoint( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl llm_router_billing_checkpointQueryTableAccess for __sdk::QueryTableAccessor { + fn llm_router_billing_checkpoint( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("llm_router_billing_checkpoint") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_type.rs new file mode 100644 index 000000000..e4348efff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_billing_checkpoint_type.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct LlmRouterBillingCheckpoint { + pub account_key: String, + pub router_user_id: i64, + pub settled_quota: u64, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for LlmRouterBillingCheckpoint { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `LlmRouterBillingCheckpoint`. +/// +/// Provides typed access to columns for query building. +pub struct LlmRouterBillingCheckpointCols { + pub account_key: __sdk::__query_builder::Col, + pub router_user_id: __sdk::__query_builder::Col, + pub settled_quota: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for LlmRouterBillingCheckpoint { + type Cols = LlmRouterBillingCheckpointCols; + fn cols(table_name: &'static str) -> Self::Cols { + LlmRouterBillingCheckpointCols { + account_key: __sdk::__query_builder::Col::new(table_name, "account_key"), + router_user_id: __sdk::__query_builder::Col::new(table_name, "router_user_id"), + settled_quota: __sdk::__query_builder::Col::new(table_name, "settled_quota"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `LlmRouterBillingCheckpoint`. +/// +/// Provides typed access to indexed columns for query building. +pub struct LlmRouterBillingCheckpointIxCols { + pub account_key: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for LlmRouterBillingCheckpoint { + type IxCols = LlmRouterBillingCheckpointIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + LlmRouterBillingCheckpointIxCols { + account_key: __sdk::__query_builder::IxCol::new(table_name, "account_key"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for LlmRouterBillingCheckpoint {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_input_type.rs new file mode 100644 index 000000000..e5ec8e75d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct LlmRouterQuotaSettlementInput { + pub owner_user_id: String, + pub route_origin: String, + pub router_user_id: i64, + pub used_quota: u64, +} + +impl __sdk::InModule for LlmRouterQuotaSettlementInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_result_type.rs new file mode 100644 index 000000000..00ff5e590 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_quota_settlement_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct LlmRouterQuotaSettlementResult { + pub ok: bool, + pub settled_quota: u64, + pub charged_points: u64, + pub spendable_points: u64, + pub error_message: Option, +} + +impl __sdk::InModule for LlmRouterQuotaSettlementResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index d2ccdcf38..ad94b2707 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -37,6 +37,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { DailyFreeReset, RechargeRefundRecovery, + + LlmRouterConsume, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/settle_llm_router_quota_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/settle_llm_router_quota_and_return_procedure.rs new file mode 100644 index 000000000..f1036481a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/settle_llm_router_quota_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::llm_router_quota_settlement_input_type::LlmRouterQuotaSettlementInput; +use super::llm_router_quota_settlement_result_type::LlmRouterQuotaSettlementResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SettleLlmRouterQuotaAndReturnArgs { + pub input: LlmRouterQuotaSettlementInput, +} + +impl __sdk::InModule for SettleLlmRouterQuotaAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `settle_llm_router_quota_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait settle_llm_router_quota_and_return { + fn settle_llm_router_quota_and_return(&self, input: LlmRouterQuotaSettlementInput) { + self.settle_llm_router_quota_and_return_then(input, |_, _| {}); + } + + fn settle_llm_router_quota_and_return_then( + &self, + input: LlmRouterQuotaSettlementInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl settle_llm_router_quota_and_return for super::RemoteProcedures { + fn settle_llm_router_quota_and_return_then( + &self, + input: LlmRouterQuotaSettlementInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, LlmRouterQuotaSettlementResult>( + "settle_llm_router_quota_and_return", + SettleLlmRouterQuotaAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4933181e1..4aad91090 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -253,6 +253,7 @@ macro_rules! migration_tables { asset_event, external_api_key, llm_router_account, + llm_router_billing_checkpoint, agc_model_catalog, error_report, editor_agent_conversation, diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 1a5196824..52f02feca 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1127,6 +1127,153 @@ pub fn grant_new_user_registration_wallet_reward( } } +#[spacetimedb::table(accessor = llm_router_billing_checkpoint)] +pub struct LlmRouterBillingCheckpoint { + #[primary_key] + pub account_key: String, + pub router_user_id: i64, + pub settled_quota: u64, + pub updated_at: Timestamp, +} + +#[derive(Clone, Debug, SpacetimeType)] +pub struct LlmRouterQuotaSettlementInput { + pub owner_user_id: String, + pub route_origin: String, + pub router_user_id: i64, + pub used_quota: u64, +} + +#[derive(Clone, Debug, SpacetimeType)] +pub struct LlmRouterQuotaSettlementResult { + pub ok: bool, + pub settled_quota: u64, + pub charged_points: u64, + pub spendable_points: u64, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn settle_llm_router_quota_and_return( + ctx: &mut ProcedureContext, + input: LlmRouterQuotaSettlementInput, +) -> LlmRouterQuotaSettlementResult { + let caller = ctx.sender(); + let result = ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let account = tx + .db + .llm_router_account() + .by_llm_router_account_owner_route() + .filter((input.owner_user_id.as_str(), input.route_origin.as_str())) + .next() + .ok_or("LLM Router 账号不存在")?; + let recorded_id = account + .router_account_id + .as_deref() + .and_then(|id| id.parse::().ok()) + .or_else(|| { + account + .account_json + .as_deref() + .and_then(|text| serde_json::from_str::(text).ok()) + .and_then(|value| value.get("routerUserId").and_then(JsonValue::as_i64)) + }); + if input.router_user_id <= 0 || recorded_id != Some(input.router_user_id) { + return Err("LLM Router 结算账号不匹配".to_string()); + } + validate_runtime_profile_wallet_debit_restrictions( + -1, + RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, + has_profile_wallet_manual_restriction(tx, &input.owner_user_id), + has_profile_recharge_refund_wallet_freeze(tx, &input.owner_user_id), + )?; + let checkpoint = tx + .db + .llm_router_billing_checkpoint() + .account_key() + .find(&account.account_key); + if checkpoint + .as_ref() + .is_some_and(|row| row.router_user_id != input.router_user_id) + { + return Err("LLM Router 结算账号已更换,需要迁移基线".to_string()); + } + let baseline = checkpoint.as_ref().map(|row| row.settled_quota); + refresh_profile_wallet_expiring_points(tx, &input.owner_user_id, tx.timestamp); + let spendable = profile_wallet_balance(tx, &input.owner_user_id).saturating_sub( + active_profile_recharge_refund_hold_points(tx, &input.owner_user_id), + ); + let plan = + module_runtime::calculate_llm_quota_settlement(baseline, input.used_quota, spendable); + if plan.charged_points > 0 { + let from_quota = baseline.ok_or("LLM Router 结算基线缺失")?; + let ledger_id = format!( + "llm_router_consume:{}:{}:{}", + account.account_key, from_quota, plan.settled_quota + ); + let metadata = json!({ + "operation": "llm-router", + "billingMode": "router-cumulative-quota", + "fromQuota": from_quota, + "settledQuota": plan.settled_quota, + "observedUsedQuota": input.used_quota, + "quotaPerPoint": module_runtime::LLM_ROUTER_QUOTA_PER_POINT, + }) + .to_string(); + apply_profile_wallet_signed_delta( + tx, + &input.owner_user_id, + -(plan.charged_points as i64), + RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, + &ledger_id, + tx.timestamp, + true, + &metadata, + )?; + } + if baseline != Some(plan.settled_quota) { + let row = LlmRouterBillingCheckpoint { + account_key: account.account_key, + router_user_id: input.router_user_id, + settled_quota: plan.settled_quota, + updated_at: tx.timestamp, + }; + if checkpoint.is_some() { + tx.db + .llm_router_billing_checkpoint() + .account_key() + .update(row); + } else { + tx.db.llm_router_billing_checkpoint().insert(row); + } + } + Ok(( + plan.settled_quota, + plan.charged_points, + spendable.saturating_sub(plan.charged_points), + )) + }); + match result { + Ok((settled_quota, charged_points, spendable_points)) => LlmRouterQuotaSettlementResult { + ok: true, + settled_quota, + charged_points, + spendable_points, + error_message: None, + }, + Err(message) => LlmRouterQuotaSettlementResult { + ok: false, + settled_quota: 0, + charged_points: 0, + spendable_points: 0, + error_message: Some(message), + }, + } +} + // 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。 #[spacetimedb::procedure] pub fn consume_profile_wallet_points_and_return( @@ -2733,7 +2880,7 @@ mod tests { } #[test] - fn historical_wallet_spend_counts_gross_asset_consumption_only() { + fn historical_wallet_spend_counts_asset_and_llm_consumption() { assert_eq!( sum_historical_profile_wallet_consumed_points([ ( @@ -2744,6 +2891,8 @@ mod tests { RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, -8 ), + (RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, -7), + (RuntimeProfileWalletLedgerSourceType::LlmRouterConsume, 2), ( RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, 40 @@ -2754,7 +2903,7 @@ mod tests { ), (RuntimeProfileWalletLedgerSourceType::PointsRecharge, 100), ]), - 48 + 55 ); } @@ -5272,8 +5421,11 @@ fn initialize_profile_wallet_consumption_projections( for row in ctx.db.profile_wallet_ledger().iter() { scanned_ledger_count = scanned_ledger_count.saturating_add(1); let total = totals.entry(row.user_id).or_default(); - if row.source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - && row.amount_delta < 0 + if matches!( + row.source_type, + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume + | RuntimeProfileWalletLedgerSourceType::LlmRouterConsume + ) && row.amount_delta < 0 { *total = total.saturating_add(row.amount_delta.unsigned_abs()); } @@ -5401,8 +5553,11 @@ fn sum_historical_profile_wallet_consumed_points( entries .into_iter() .fold(0_u64, |total, (source_type, amount_delta)| { - if source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - && amount_delta < 0 + if matches!( + source_type, + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume + | RuntimeProfileWalletLedgerSourceType::LlmRouterConsume + ) && amount_delta < 0 { total.saturating_add(amount_delta.unsigned_abs()) } else { @@ -9529,58 +9684,8 @@ fn apply_profile_wallet_adjustment( ) .map_err(|error| error.to_string())?; let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - let requested_unsigned_delta = - convert_runtime_profile_wallet_unsigned_delta(validated_input.amount) - .map_err(|error| error.to_string())?; - let best_effort_llm_router = consume - && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - && metadata_json_is_llm_router_best_effort(&validated_input.metadata_json); - - // LLM Router billing is deliberately best effort after the upstream - // response has completed: the request is charged up to the user's current - // spendable balance and any remainder is waived. Reusing the same ledger - // id must stay idempotent even when the originally requested amount was - // larger than the amount that could actually be charged, so resolve an - // existing best-effort ledger before calculating the current debit. - if best_effort_llm_router { - if let Some(existing) = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&validated_input.ledger_id.to_string()) - { - if existing.user_id != validated_input.user_id - || existing.source_type - != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - { - return Err("LLM Router 钱包幂等流水身份不匹配".to_string()); - } - return get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ); - } - } - - let unsigned_delta = if best_effort_llm_router { - refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&validated_input.user_id.to_string()); - let wallet_total = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let held_points = active_profile_recharge_refund_hold_points(ctx, &validated_input.user_id); - calculate_runtime_profile_wallet_best_effort_debit( - requested_unsigned_delta, - wallet_total, - held_points, - ) - } else { - requested_unsigned_delta - }; + let unsigned_delta = convert_runtime_profile_wallet_unsigned_delta(validated_input.amount) + .map_err(|error| error.to_string())?; if consume { if !validated_input .ledger_id @@ -9648,11 +9753,6 @@ fn apply_profile_wallet_adjustment( unsigned_delta }; - let settlement_metadata_json = if best_effort_llm_router { - metadata_json_with_llm_router_charge_summary(&validated_input.metadata_json, unsigned_delta) - } else { - validated_input.metadata_json.clone() - }; apply_profile_wallet_signed_delta( ctx, &validated_input.user_id, @@ -9661,7 +9761,7 @@ fn apply_profile_wallet_adjustment( &validated_input.ledger_id, created_at, true, - &settlement_metadata_json, + &validated_input.metadata_json, )?; if !consume { record_asset_operation_wallet_settlement( @@ -9680,43 +9780,6 @@ fn apply_profile_wallet_adjustment( ) } -fn metadata_json_is_llm_router_best_effort(metadata_json: &str) -> bool { - serde_json::from_str::(metadata_json) - .ok() - .and_then(|metadata| { - metadata - .get("billingMode") - .and_then(JsonValue::as_str) - .map(|mode| mode == "llm-router-best-effort") - }) - .unwrap_or(false) -} - -fn metadata_json_with_llm_router_charge_summary( - metadata_json: &str, - charged_points: i64, -) -> String { - let Ok(mut metadata) = serde_json::from_str::(metadata_json) else { - return metadata_json.to_string(); - }; - let Some(object) = metadata.as_object_mut() else { - return metadata_json.to_string(); - }; - let requested_points = object - .get("requestedPoints") - .and_then(JsonValue::as_u64) - .unwrap_or(charged_points.max(0) as u64); - object.insert( - "chargedPoints".to_string(), - JsonValue::from(charged_points.max(0)), - ); - object.insert( - "waivedPoints".to_string(), - JsonValue::from(requested_points.saturating_sub(charged_points.max(0) as u64)), - ); - serde_json::to_string(&metadata).unwrap_or_else(|_| metadata_json.to_string()) -} - pub(crate) fn settle_external_generation_attempt_refund( ctx: &ReducerContext, job_id: &str, @@ -10512,8 +10575,11 @@ fn apply_profile_wallet_signed_delta( metadata_json: Some(ledger_metadata_json), }); - if source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - && amount_delta < 0 + if matches!( + source_type, + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume + | RuntimeProfileWalletLedgerSourceType::LlmRouterConsume + ) && amount_delta < 0 { record_profile_wallet_consumption( ctx, diff --git a/src/components/platform-entry/platformProfileFundsModel.ts b/src/components/platform-entry/platformProfileFundsModel.ts index 23a83a59d..2875aa7af 100644 --- a/src/components/platform-entry/platformProfileFundsModel.ts +++ b/src/components/platform-entry/platformProfileFundsModel.ts @@ -14,6 +14,7 @@ const PLATFORM_WALLET_LEDGER_SOURCE_LABELS = { daily_free_grant: '每日免费发放', daily_free_reset: '每日免费重置', asset_operation_consume: '资产操作消耗', + llm_router_consume: 'LLM 调用消耗', asset_operation_refund: '资产操作退回', recharge_refund_recovery: '充值退款追回', redeem_code_reward: '兑换码奖励',