接入 LLM Router 累计额度结算
Project CI / Repository checks (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Backend tests (pull_request) Successful in 7m35s
Project CI / Native shell tests (pull_request) Failing after 7m43s

按 Router used_quota 累计值与首次基线结算泥点
新增原子 checkpoint 事务及 llm_router_consume 钱包流水
同步额度查询校验、前端展示、生成绑定和技术文档
This commit is contained in:
2026-09-06 00:36:31 +08:00
parent 78f547d26d
commit 736a1b6ac6
31 changed files with 1171 additions and 580 deletions
+2
View File
@@ -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 清单格式和下载约定。
@@ -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` 的无限额度 TokenToken/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 环境隔离与测试门禁
@@ -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。
@@ -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`;不存凭据。
@@ -226,7 +226,7 @@ AGC loopback Provider ProxyBearer=平台 access token
-> POST https://router.genarrative.world/v1/responsesBearer=<router-api-key>
```
`/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 读取。
@@ -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');
@@ -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: '兑换码奖励',
+1
View File
@@ -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'
+11 -1
View File
@@ -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<String> {
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]
@@ -897,7 +897,7 @@ async fn ensure_router_subscription(
Ok(())
}
fn router_user_id_from_account(record: &LlmRouterAccountRecord) -> Option<i64> {
pub(crate) fn router_user_id_from_account(record: &LlmRouterAccountRecord) -> Option<i64> {
record
.router_account_id
.as_deref()
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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");
@@ -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",
}
}
}
@@ -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,
@@ -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<u64>,
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
);
}
}
+1
View File
@@ -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,
@@ -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<u64, String> {
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<Value, String> {
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<u64, String> {
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<Vec<String>>) {
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("连接失败"));
}
}
}
@@ -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 =
@@ -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
}
@@ -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<LlmRouterQuotaSettlementRecord, SpacetimeClientError> {
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,
@@ -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<JumpHopRuntimeRunRow>,
jump_hop_work_profile: __sdk::TableUpdate<JumpHopWorkProfileRow>,
llm_router_account: __sdk::TableUpdate<LlmRouterAccount>,
llm_router_billing_checkpoint: __sdk::TableUpdate<LlmRouterBillingCheckpoint>,
match_3_d_agent_message: __sdk::TableUpdate<Match3DAgentMessageRow>,
match_3_d_agent_session: __sdk::TableUpdate<Match3DAgentSessionRow>,
match_3_d_runtime_run: __sdk::TableUpdate<Match3DRuntimeRunRow>,
@@ -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::<LlmRouterAccount>("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::<LlmRouterBillingCheckpoint>(
"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::<Match3DAgentMessageRow>(
"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::<LlmRouterBillingCheckpoint>(
"llm_router_billing_checkpoint",
&self.llm_router_billing_checkpoint,
event,
);
callbacks.invoke_table_row_callbacks::<Match3DAgentMessageRow>(
"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",
@@ -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<LlmRouterBillingCheckpoint>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
/// Lifetime-aware accessor marker for the table `llm_router_billing_checkpoint`.
pub struct LlmRouterBillingCheckpointTableAccessor;
impl __sdk::TableAccessor<super::RemoteTables> 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::<LlmRouterBillingCheckpoint>("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<Item = LlmRouterBillingCheckpoint> + '_ {
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<Item = LlmRouterBillingCheckpoint> + '_ {
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<LlmRouterBillingCheckpoint, String>,
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::<String>("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<LlmRouterBillingCheckpoint> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache
.get_or_make_table::<LlmRouterBillingCheckpoint>("llm_router_billing_checkpoint");
_table.add_unique_constraint::<String>("account_key", |row| &row.account_key);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<LlmRouterBillingCheckpoint>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<LlmRouterBillingCheckpoint>", "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<LlmRouterBillingCheckpoint>;
}
impl llm_router_billing_checkpointQueryTableAccess for __sdk::QueryTableAccessor {
fn llm_router_billing_checkpoint(
&self,
) -> __sdk::__query_builder::Table<LlmRouterBillingCheckpoint> {
__sdk::__query_builder::Table::new("llm_router_billing_checkpoint")
}
}
@@ -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<LlmRouterBillingCheckpoint, String>,
pub router_user_id: __sdk::__query_builder::Col<LlmRouterBillingCheckpoint, i64>,
pub settled_quota: __sdk::__query_builder::Col<LlmRouterBillingCheckpoint, u64>,
pub updated_at: __sdk::__query_builder::Col<LlmRouterBillingCheckpoint, __sdk::Timestamp>,
}
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<LlmRouterBillingCheckpoint, String>,
}
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 {}
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More