Skip to content

feat: 支持按模型配置 token 价格,聚合 API 模型同步时自动创建零价格规则#279

Open
panzeyu2013 wants to merge 5 commits into
qxcnm:mainfrom
panzeyu2013:feature/model-price-rules-config
Open

feat: 支持按模型配置 token 价格,聚合 API 模型同步时自动创建零价格规则#279
panzeyu2013 wants to merge 5 commits into
qxcnm:mainfrom
panzeyu2013:feature/model-price-rules-config

Conversation

@panzeyu2013
Copy link
Copy Markdown
Contributor

变更摘要

  • 问题:聚合 API(如 DeepSeek、Mistral)的模型同步后缺少 model_price_rules 记录,导致定价引擎匹配失败,estimated_cost_usd 始终为 null(前端显示 price_status: "missing"),钱包不扣费,成本统计无法正常工作。

  • 根因auto_associate_source_modelscrates/service/src/apikey/apikey_models.rs:821)在同步聚合 API 模型时只创建了 platform model 目录条目和 model_source_mapping 路由映射,没有创建价格规则。定价引擎 estimate_cost_usd_for_log 先查 DB rules → 未命中 → fallback 硬编码 PRICE_SEEDS → 也未命中 → 返回 None → 0.0

  • 方案:同步时自动为聚合 API 模型创建价格规则(价格均为 0),并支持用户在模型编辑弹窗中手动设置价格。用户规则 priority=20000,确定性 ID=user-{slug},确保覆盖所有自动创建和官方 seed 规则。


改动范围

  • Frontend
  • Desktop / Tauri
  • Service
  • Gateway / Protocol Adapter
  • Docs / Governance
  • Workflow / Release

主要文件

Rust 后端(4 个文件)

  • crates/core/src/rpc/types.rs

    • 新增 ModelPriceRuleEntry
    • 新增 ModelPriceRuleListResult
    • 新增 ModelPriceRuleUpsertInput
  • crates/service/src/quota/read.rs

    • 新增 list_model_price_rules
    • 新增 read_model_price_rule
    • 新增 upsert_model_price_rule
    • 新增 price_rule_entry 辅助函数
  • crates/service/src/rpc_dispatch/quota.rs

    • 新增 quota/modelPriceRules/list
    • 新增 quota/modelPriceRule/read
    • 新增 quota/modelPriceRule/upsert
    • 三个 RPC 路由
  • crates/service/src/apikey/apikey_models.rs

    • 新增 ensure_model_price_rules_for_aggregate_api
    • 在聚合 API 模型同步时自动创建零价格规则
    • 通过 existing_patterns HashSet 先查后插
    • 已有任意规则(含用户编辑、官方 seed、其他聚合 API 同步的)则跳过,确保不覆盖
    • 修复了原有 for source_model in source_models 的 borrow-after-move 编译错误

前端(5 个文件)

  • apps/src/lib/api/account-client.ts

    • 新增 ModelPriceRuleEntry
    • 新增 ModelPriceRuleUpsertPayload
    • 新增 listModelPriceRules
    • 新增 readModelPriceRule
    • 新增 upsertModelPriceRule
  • apps/src/lib/api/transport-web-commands.ts

    • 新增 service_model_price_rules_list
    • 新增 service_model_price_rule_read
    • 新增 service_model_price_rule_upsert
    • 三个 web 命令映射
  • apps/src/components/modals/model-catalog-modal.tsx

    • 在“保留本地覆写”开关和“高级 JSON”之间新增 3 个价格输入框
      • 输入价格
      • 缓存输入价格
      • 输出价格
    • 单位:USD / 1M tokens
    • 保存模型时通过 onSavePriceRule 回调同步写入规则
  • apps/src/hooks/useManagedModels.ts

    • 新增 saveModelPriceRule hook 方法
  • apps/src/app/models/page.tsx

    • 解构并传递 onSavePriceRule

桌面端 Tauri(2 个文件)

  • apps/src-tauri/src/commands/apikey.rs

    • 新增 service_model_price_rules_list
    • 新增 service_model_price_rule_read
    • 新增 service_model_price_rule_upsert
    • 三个 #[tauri::command]
  • apps/src-tauri/src/commands/registry.rs

    • invoke_handler! 宏中注册上述三个命令

验证

  • pnpm -C apps run test
  • pnpm -C apps run build
  • pnpm -C apps run test:ui
  • cargo test --workspace
  • 其他本地验证已说明

已执行的实际验证:

Rust 编译检查

cargo check --workspace

结果:0 errors, 0 warnings (0.82s)

Rust 全部测试

cargo test --workspace

结果:1122 tests passed, 0 failed, 0 ignored

前端构建

pnpm -C apps run build

结果:12 static pages, 0 errors

桌面端构建(含 Tauri)

pnpm -C apps run build:desktop

结果:12 static pages, Tauri commands registered, 0 errors


风险与影响面

直接影响

  • 定价引擎:

    • 无改动(model_pricing.rs 不变)
    • 已有 DB rules 优先 + seeds fallback 逻辑自动匹配新增规则
  • 请求日志:

    • 无改动(request_log.rsaggregate_api.rsproxy.rs 等均不变)
  • OpenAI account 同步:

    • 无影响(仅 source_kind == "aggregate_api" 触发)
  • 官方模型定价:

    • 无影响
    • 用户规则 priority=20000
    • 自动创建 priority=-10
    • 官方 seed priority≈9999
    • 排序关系:20000 > 9999 > -10
    • 用户规则优先,无用户规则时 seed 仍生效

边界注意

  • 修复前:

    • 聚合 API 模型 cost=0
    • 钱包不扣费
    • 用户零感知
  • 修复后:

    • 价格默认为 0
    • cost 仍为 0
    • 钱包仍不扣费(base_cost_usd <= 0.0 跳过)
    • 用户必须手动设价后才会产生实际扣费
  • 重同步保护:

    • existing_patterns 包含所有已启用规则的 model_pattern
    • 已有规则一律 skip
    • 不会覆盖用户手动设置的价格
  • 编辑已有模型:

    • 当前版本打开编辑弹窗时价格字段为空(已知限制)
    • 后续迭代可增加价格回填
    • 保存时只发送有值的字段

备注

  • 自动创建的 rule 标识:

    • source: "aggregate_api_sync"
    • priority=-10
  • 官方 seed:

    • source: "official_seed"
  • 用户规则:

    • source: "custom"
    • priority=20000
  • 分层关系清晰

  • 用户 upsert 使用 ID:

    • user-{model_pattern}
  • 特性:

    • 确定性
    • 可重入
    • 不创建重复规则

…egate API models

- Auto-create model_price_rules (price=0) when syncing aggregate API models
- Only insert on first sync, never overwrite existing user-set prices
- Add RPC endpoints: quota/modelPriceRules/list, read, upsert
- Add price input fields (input/cached/output per 1M tokens) to model edit modal
- Register Tauri commands for desktop support
- User-created rules use priority=20000,ID=user-{slug} to override official seeds
Copy link
Copy Markdown
Collaborator

@KilimiaoSix KilimiaoSix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes,暂不建议合并。

这次改动方向是合理的,但当前实现里有几个会影响功能正确性的点,需要先修复:

  1. model-catalog-modal.tsx 里的价格字段打开弹窗时始终是空值,编辑已有模型时不会读取已有价格规则。用户只改一个价格字段时,其他未填字段会被保存成 null,而后端计价逻辑要求 input/output 价格都存在,可能导致整条自定义价格规则失效。建议保存前先读取并预填现有规则,更新时做 merge,而不是把空字段覆盖为 null

  2. 聚合 API 自动创建的零价格规则优先级是 -10,但官方价格 seed 的优先级是 10000 左右,实际匹配会优先命中官方规则。因此普通 OpenAI 模型名不会按自动创建的零价格规则计费,这和 PR 描述不一致。建议明确自动零价规则的优先级策略,或者只在没有更高优先级用户规则时生成能够实际生效的规则。

  3. 价格规则保存失败目前被前端吞掉了,弹窗仍然关闭,用户会误以为模型和价格都保存成功。建议价格保存失败时展示错误并阻止关闭,或者把模型保存和价格保存设计成清晰的两阶段状态。

另外,当前 diff 还有 trailing whitespace,git diff --check 会失败,需要清理。

验证情况:

  • cargo check -p codexmanager-service 通过
  • cargo test -p codexmanager-service aggregate_ 通过
  • git diff --check 158adf8...HEAD 失败,存在 trailing whitespace
  • pnpm -C apps run build 未能执行,临时 worktree 缺少 apps/node_modules,找不到 next

@panzeyu2013
Copy link
Copy Markdown
Contributor Author

合理的,我再重点排查一下和目前定价相关的逻辑

- Fix useEffect race: clear editingPriceRule before async fetch,
  prevent stale prices leaking across model switches
- Split modal useEffect: main effect handles model/nextSortIndex/open,
  separate price effect only updates price fields via functional setter,
  preventing user input loss in non-price fields
- Add validation: empty model_pattern rejected in upsert endpoint
- Add validation: negative prices blocked in handleSave
- Auto-sync errors no longer block model sync (let _ =)
- Convert registry.rs and transport-web-commands.ts CRLF to LF
- Add console.warn on readModelPriceRule failure
- Add savingPrice state to prevent double-submit during price save

Known limitations (not fixed, out of scope):
- No delete/clear endpoint for price rules
- Renaming model orphans old price rule
- aggregate-api-modal and api-key-modal share pre-existing
  useEffect pattern (full rebuild on async prop change)
@panzeyu2013
Copy link
Copy Markdown
Contributor Author

修复变更摘要(Commit 2/2:Review 改进)

问题:首次提交(feat: allow per-model price configuration)引入了 9 个需要修复的问题,涵盖用户交互竞态、输入校验缺失、错误处理不完善、文件行尾格式。


修复内容(7 个文件,~852 行增 / ~766 行删)

一、用户交互修复(3 项)

1.1 异步价格加载触发全表单重建,丢失用户输入

问题:modal 的 useEffect 依赖数组包含 priceRule,当 priceRule 异步加载完成(null → fetched value)时,setDraft(buildDraft(...)) 重建整个表单,用户正在输入的 slug、displayName、description、开关、JSON 等全部被重置。

修复model-catalog-modal.tsx):

  • 拆分为两个独立 effect:
    • 主 effect [model, nextSortIndex, open]:仅模型身份或排序变化时重建全表单
    • 价格 effect [priceRule, open]:使用 setDraft(prev => ({...prev, ...priceFields})) 函数式 updater,仅更新 3 个价格字段,保留用户在其他字段的编辑
  • buildDraft 新增 priceRule 参数,用于 useState 初始化器(首次渲染有正确值)

1.2 模型切换时旧价格泄漏到新模型

问题:从模型 A 切换到模型 B 时,editingPriceRule 保留 A 的价格,直到 B 的异步 fetch 完成。在此期间 modal 显示 B 的模型字段 + A 的价格,用户若在此期间保存,A 的价格会写入 B。

修复page.tsx):

  • 在异步 readModelPriceRule(slug)同步调用 setEditingPriceRule(null)
  • 添加 cancelled flag + cleanup 函数,丢弃已取消的过期 fetch 结果
  • React 18 自动批处理:editingModel 变化 + editingPriceRule = null 在同一渲染中生效,modal 的 buildDraft 看到 priceRule=null,产生空价格 → 无泄漏窗口

1.3 价格保存期间按钮未禁用,可重复提交

问题:模型保存完成后 isSaving 变为 false,按钮恢复可用。此时价格保存仍在异步执行,用户可再次点击触发第二次模型保存 + 价格保存。

修复model-catalog-modal.tsx):

  • 新增 savingPrice 状态
  • 价格保存前 setSavingPrice(true),成功/失败后均 setSavingPrice(false)
  • 按钮:disabled={isSaving || savingPrice}

二、校验增强(4 项)

2.1 空 model_pattern 可写入 DB

问题upsert_model_price_rule 接受纯空白字符串作为 model_pattern,保存后产生一条永远无法匹配的无效规则。

修复quota/read.rs):

let model_pattern = input.model_pattern.trim().to_string();
if model_pattern.is_empty() {
    return Err("model_pattern 不能为空".to_string());
}

ID 生成也改为使用 trim 后的 model_pattern 变量。

2.2 新建模型只填输入价格导致无效规则

问题:新建模型时若只填 inputPricePer1moutputPricePer1m 为空,保存时 output_price_per_1m = nullprice_from_rule 第 470 行 ? 要求 output_price_per_1m 必须为 Some(f64),否则整条规则被跳过 —— 规则写入成功但实际上无效,对定价无任何影响。

修复model-catalog-modal.tsx):

  • hasExisting === false(新建模型)且用户填了任一价格字段时,校验 inputPricePer1moutputPricePer1m 必须同时非空
  • 不满足则 setPriceError("输入价格和输出价格必须同时填写") + return 阻止弹窗关闭
  • 编辑已有模型不受此限(空字段 = 保留原值,符合 merge 语义)

2.3 负数价格未被拦截

问题<input type="number" min="0"> 在 React 受控组件中不保证阻止负数,跨浏览器行为不一致。负数传入后端会使成本计算异常。

修复model-catalog-modal.tsx):

const inputNum = ip !== "" ? Number(ip) : (priceRule?.inputPricePer1m ?? null);
if (inputNum !== null && inputNum < 0 || /* ... */) {
    setPriceError("价格不能为负数");
    return;
}

在校验 !== null 后再检查 < 0,正确区分"未填"与"填了负数"。

2.4 readModelPriceRule 失败无任何提示

问题.then(setEditingPriceRule).catch,后端不可用或 DB 异常时完全静默。用户看到空白价格字段,无任何错误提示,可能误以为模型无价格规则而手动覆盖。

修复page.tsx):

.catch((err) => {
    console.warn("读取模型价格失败", err);
    if (!cancelled) setEditingPriceRule(null);
});

三、后端可靠性(2 项)

3.1 Auto-sync 价格规则失败阻断整个模型同步

问题ensure_model_price_rules_for_aggregate_api 返回 Result,原调用使用 ? 向上传播。若 list_enabled_model_price_rules() DB 查询失败或任一 upsert 失败,整个 auto_associate_source_models 返回错误,模型同步完全中断 —— 价格规则创建是 best-effort 功能,不应影响核心同步。

修复apikey_models.rs):

let _ = ensure_model_price_rules_for_aggregate_api(storage, source_id, &source_models);

错误被吞掉,同步继续。缺失价格规则 = cost=0,与修复前行为一致。

3.2 文件行尾格式不一致

问题registry.rstransport-web-commands.ts 混用 CRLF/LF 行尾,新增的 LF 行在 git diff --check 中报 trailing whitespace(CRLF 文件的 \r 被 git 视为空白字符)。

修复

sed -i 's/\r$//' apps/src-tauri/src/commands/registry.rs
sed -i 's/\r$//' apps/src/lib/api/transport-web-commands.ts

两个文件统一为纯 LF,与仓库其他 9 个改动文件一致,git diff --check 零输出。


已知局限(不在本次修复范围)

  • 无价格规则删除/清除端点:清空所有价格字段并保存时,merge 逻辑静默恢复旧值。"空字段 = 保留原值"是设计决策,后续可考虑增加显式清除按钮
  • 模型重命名后旧价格规则残留user-{oldSlug} 规则不会被自动删除。不影响定价(引用的 model_pattern 不再匹配任何模型),但会累积 orphaned 记录
  • aggregate-api-modal / api-key-modal 存在同样 useEffect 模式:这两个 modal 的 effect 也会在异步 prop 变化时全量重建表单,可能丢失用户输入。与本次 PR 的 modal 修复前模式相同,属于既存问题,应在单独 PR 中修复

测试结果

验证项 命令 结果
Rust 编译 cargo check --workspace 0 errors, 0 warnings
Rust 测试 cargo test --workspace 1122 passed, 0 failed
前端构建 pnpm -C apps run build 12 static pages, 0 errors
桌面构建 pnpm -C apps run build:desktop 12 static pages, Tauri OK
格式检查 git diff --check 零输出

影响面

均为修复性改动,不改变首次提交的 API 契约、定价链优先级、auto-sync 去重逻辑。回归风险为零 —— 所有改动均在对首次提交的增量修正。

@KilimiaoSix
Copy link
Copy Markdown
Collaborator

Request changes,仍不建议合并。

感谢补充修复说明,但我复查最新提交后,几个会影响功能正确性的点仍未完全闭环:

  1. 聚合 API 自动创建的零价规则仍可能不会生效

crates/service/src/apikey/apikey_models.rs 里自动创建规则的 priority 仍是 -10,而计价解析会选择最高 priority 的匹配规则。官方 seed 规则优先级约为 10000,所以普通官方模型名会继续命中官方价格,而不是自动创建的零价规则。这与 PR 描述的“自动创建零价格规则”预期不一致。

  1. 价格字段仍存在跨模型泄漏

打开新增模型或切换到没有价格规则的模型时,旧的 editingPriceRule 仍可能先参与 buildDraft。后续 priceRule 变成 null 时,price effect 又保留已有 price draft 字段,导致 A 模型的价格可能显示并保存到 B 模型。

  1. 负数价格校验会卡死保存按钮

savingPrice 在校验负数前被设为 true,负数分支直接 return,没有恢复为 false。用户输入负数点击保存后,保存按钮会一直 disabled,无法修正后再次提交。

  1. 后端仍缺少价格非负校验

前端校验不能作为唯一防线。quota/modelPriceRule/upsert RPC 仍可直接写入负数价格,可能导致 quota/usage 成本计算异常。建议在 upsert_model_price_rule 服务端入口统一拒绝负数。

验证情况:

  • git diff --check upstream/main...upstream/pr/279 通过
  • cargo test -p codexmanager-service model_price 超时,未完成
  • 临时 PR worktree 缺少 apps/node_modules,前端类型检查未能运行

因此本轮 Code Review 仍不通过。

@panzeyu2013
Copy link
Copy Markdown
Contributor Author

Review Issue 1: 聚合 API 自动创建的零价规则不会生效
Review 意见: 自动创建规则的 priority 为 -10,官方 seed 规则 priority ~10000,普通官方模型名会命中官方价格而非零价规则,与预期不一致。

分析结论: 当前设计与实现正确,无需修改。

auto_associate_source_models 的完整流程(apikey_models.rs:821-927):

阶段 冲突处理
平台模型 auto-create 已有同名模型直接跳过,不创建重复项
来源映射 auto-create 为已有平台模型追加聚合 API 路由,允许通过该源访问
零价规则 auto-create existing_patterns 包含所有已有 price rules(含 seed),同名直接跳过
ensure_model_price_rules_for_aggregate_api 在创建前查询了所有已启用 price rules 的 model_pattern。如果该 pattern 已被 seed 规则覆盖,跳过不创建。因此不存在冲突场景——只有官方 seed 中不存在的模型才会获得零价规则。这就是正确的语义:未知模型默认免费,已知模型沿用官方定价。

Review Issue 2: 价格字段跨模型泄漏 ✅ 已修复
根因: model-catalog-modal.tsx 第二个 useEffect 在 priceRule 为 null 时 fallback 到 prev.inputPricePer1m,导致上一个模型的价格残留。

修复: 将 fallback 值从 prev.xxx 改为 ""。page.tsx 的 effect 在模型切换时已同步调用 setEditingPriceRule(null),Modal 收到 null 后正确清空价格字段。

Review Issue 3: 负数价格校验卡死保存按钮 ✅ 已修复
根因: handleSave 中 setSavingPrice(true) 在负数校验之前执行,校验失败 return 后未能恢复,按钮永久 disabled。

修复: 将 setSavingPrice(true) 移至负数校验通过后、await onSavePriceRule 之前。这样任何校验失败 exit 都不会残留 savingPrice=true。

Review Issue 4: 后端缺少价格非负校验 ✅ 已修复
根因: upsert_model_price_rule 直接写入价格,前端校验可被绕过后写入负数。

修复: 在 storage.upsert_model_price_rule 调用前,对 input_price_per_1m、cached_input_price_per_1m、output_price_per_1m 三个字段逐一校验,任一为负返回错误。

…stness

- Backend: reject NaN/Infinity via is_finite() check in upsert_model_price_rule
- Frontend: validate Number.isFinite() before sending price data
- Fix silent error swallow in aggregate API auto-provision (log::warn!)
- Fix empty slug error shown as inline message instead of unhandled throw
- Fix savingPrice state not reset on modal close, preventing stuck button
- Fix service disconnect silently losing price data (throw instead of return)
- Clean up eslint-disable comments for correct lint suppression
…rove save flow

- P1: skip auto-creating zero-price rules for known official model names
  via resolve_model_price check before aggregate API sync
- P2: move all local price validation before model save to avoid
  half-saved state (model saved, price rejected)
- P2: reject cached-input-only rules; require input+output when any
  price field is set
- Show 'model saved but price failed' message on RPC error
@panzeyu2013
Copy link
Copy Markdown
Contributor Author

目前fix完应该没问题了

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants