Skip to content

Commit ba9e618

Browse files
committed
fix(ai): 成本失真修复——模型单价映射表 + 预估含输出 token + 未知模型警告 (F1-A3)
1 parent 897ee96 commit ba9e618

7 files changed

Lines changed: 133 additions & 25 deletions

File tree

‎app/src-tauri/src/ai_cost.rs‎

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,61 @@
1-
//! AI 成本估算(REQ-143 基础版,v0.8.0 M2)。
1+
//! AI 成本估算(REQ-143 基础版 + 2026-08-21 F1 成本失真修复)。
22
//!
33
//! @ai-context: 精修/补充触发前按字符数估算 token → 费用=token×单价 →
44
//! 确认弹窗(首次必显 + 内联余额 + "记住此选择"偏好持久化在
5-
//! ai_settings.remember_cost_choice);单价表可配
6-
//! (env SILICONFLOW_PRICE_PER_1M_TOKENS 覆盖——环境隔离铁律)。
5+
//! ai_settings.remember_cost_choice)。
6+
//! @ai-context: F1 修复(2026-08-21):① 单价映射表——按模型取单价
7+
//! (内置免费档 ¥0 + 常见质量档实测价;未知模型回退 ¥0 并警告,
8+
//! 消灭"切付费模型仍显示 ¥0"的成本失真);② 预估含输出 token
9+
//! (输出=输入×输出比系数,保守上界——只算输入会把长精修
10+
//! 费用低估 2-3 倍);③ env SILICONFLOW_PRICE_PER_1M_TOKENS
11+
//! 仍可整体覆盖(开发路径,AGENTS.md 环境隔离铁律)。
712
//! @ai-context: 估算为近似值:中文 1 字符 ≈ 1 token(保守上界,宁可高估
813
//! 不可低估——用户确认后不产生"费用超预期");预估与实际偏差
914
//! 记录校准单价表(M4 note_ai_usage 落库后比对)。
1015
16+
use std::collections::HashMap;
17+
1118
/// 默认单价(元/百万 token;免费档 0——2026-08 选型 R1-0528-Qwen3-8B ¥0/M)。
1219
pub const DEFAULT_PRICE_PER_1M: f64 = 0.0;
13-
/// env 覆盖键(元/百万 token)。
20+
/// env 覆盖键(元/百万 token;整体覆盖映射表——开发路径)。
1421
const PRICE_ENV_KEY: &str = "SILICONFLOW_PRICE_PER_1M_TOKENS";
22+
/// 输出 token 估算系数(输入→输出比例;精修/补充是重写型任务,输出量
23+
/// 接近输入量——1.0 保守上界;实测校准随 golden 冒烟)。
24+
const OUTPUT_RATIO: f64 = 1.0;
25+
/// 未知模型警告文案(确认弹窗可见——成本透明铁律)。
26+
const UNKNOWN_MODEL_WARN: &str = "(该模型单价未登记,费用可能不准确)";
27+
28+
/// 内置单价映射表(模型名 → 元/百万 token)。
29+
///
30+
/// @ai-context: 2026-08 实测档:免费档 ¥0(R1-0528-Qwen3-8B,MIT 商用);
31+
/// 质量档价格按 v0.8.0 开放问题实测定档后更新(当前登记占位
32+
/// 实测值,待 golden 冒烟校准——见 docs/versions/v0.8.0.md
33+
/// 开放问题「模型选型」)。
34+
fn builtin_prices() -> HashMap<&'static str, f64> {
35+
let mut m = HashMap::new();
36+
m.insert("deepseek-ai/DeepSeek-R1-0528-Qwen3-8B", 0.0);
37+
m.insert("deepseek-ai/DeepSeek-V3-0324", 2.0);
38+
m.insert("Qwen/Qwen3-235B-A22B", 2.0);
39+
m
40+
}
41+
42+
/// 按模型取单价(元/百万 token):env 整体覆盖 > 映射表 > 默认 0 + 警告。
43+
///
44+
/// @ai-context: 返回值 (单价, 是否已知模型)——未知模型单价 0 但标记警告,
45+
/// 前端确认弹窗展示"费用可能不准确"(不静默)。
46+
pub fn price_for_model(model: &str) -> (f64, bool) {
47+
if let Ok(v) = std::env::var(PRICE_ENV_KEY) {
48+
if let Ok(p) = v.parse::<f64>() {
49+
if p.is_finite() && p >= 0.0 {
50+
return (p, true);
51+
}
52+
}
53+
}
54+
match builtin_prices().get(model) {
55+
Some(p) => (*p, true),
56+
None => (0.0, false),
57+
}
58+
}
1559

1660
/// token 估算(纯函数:字符数 × 1.0——中文 1 字符≈1 token 保守上界)。
1761
pub fn estimate_tokens(chars: usize) -> usize {
@@ -29,40 +73,56 @@ pub fn estimate_cost(tokens: usize, price_per_1m: f64) -> f64 {
2973
}
3074

3175
/// 单价解析(env 覆盖;缺省/非法 → 默认 0——免费档兜底)。
76+
///
77+
/// @ai-context: 保留向后兼容(旧调用方);新代码走 price_for_model。
3278
pub fn price_per_1m() -> f64 {
33-
std::env::var(PRICE_ENV_KEY)
34-
.ok()
35-
.and_then(|v| v.parse::<f64>().ok())
36-
.filter(|p| p.is_finite() && *p >= 0.0)
37-
.unwrap_or(DEFAULT_PRICE_PER_1M)
79+
price_for_model("").0
3880
}
3981

40-
/// 成本预估(确认弹窗数据源:token + 费用 + 单价)。
82+
/// 成本预估(确认弹窗数据源:token + 费用 + 单价 + 未知模型警告)。
4183
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
4284
#[serde(rename_all = "camelCase")]
4385
pub struct CostEstimate {
4486
pub est_tokens: usize,
4587
pub est_cost_yuan: f64,
4688
pub price_per_1m: f64,
89+
/// 该模型单价是否已登记(false → 前端显示"费用可能不准确"警告)
90+
pub price_known: bool,
4791
}
4892

49-
/// 按内容字符数估算(纯函数;单价取当前 env/默认)。
50-
pub fn estimate_for_content(chars: usize) -> CostEstimate {
93+
/// 按内容字符数估算(纯函数;单价按模型映射)。
94+
///
95+
/// @ai-context: F1 修复:输出 token = 输入 × OUTPUT_RATIO(重写型任务
96+
/// 保守上界),总 token = 输入 + 输出;模型未知 → 单价 0 +
97+
/// price_known=false(前端警告,不静默)。
98+
pub fn estimate_for_content_model(chars: usize, model: &str) -> CostEstimate {
5199
let tokens = estimate_tokens(chars);
52-
let price = price_per_1m();
100+
let total = tokens.saturating_add((tokens as f64 * OUTPUT_RATIO) as usize);
101+
let (price, known) = price_for_model(model);
53102
CostEstimate {
54-
est_tokens: tokens,
55-
est_cost_yuan: estimate_cost(tokens, price),
103+
est_tokens: total,
104+
est_cost_yuan: estimate_cost(total, price),
56105
price_per_1m: price,
106+
price_known: known,
57107
}
58108
}
59109

110+
/// 按内容字符数估算(兼容旧签名——免费档默认模型,未知模型警告保留)。
111+
pub fn estimate_for_content(chars: usize) -> CostEstimate {
112+
estimate_for_content_model(chars, "")
113+
}
114+
60115
/// 成本记录费用(纯函数:输入+输出 token × 当前单价——与预估同口径,
61116
/// M4 落库 note_ai_usage 用)。
62117
pub fn usage_cost(tokens_in: usize, tokens_out: usize) -> f64 {
63118
estimate_cost(tokens_in.saturating_add(tokens_out), price_per_1m())
64119
}
65120

121+
/// 未知模型警告文案(确认弹窗拼接用)。
122+
pub fn unknown_model_warning() -> &'static str {
123+
UNKNOWN_MODEL_WARN
124+
}
125+
66126
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
67127
#[cfg(test)]
68128
#[path = "ai_cost_tests.rs"]

‎app/src-tauri/src/ai_cost_tests.rs‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
44
use std::sync::Mutex;
55

6-
use crate::ai_cost::{estimate_cost, estimate_for_content, estimate_tokens, price_per_1m, DEFAULT_PRICE_PER_1M};
6+
use crate::ai_cost::{
7+
estimate_cost, estimate_for_content, estimate_for_content_model, estimate_tokens,
8+
price_for_model, price_per_1m, DEFAULT_PRICE_PER_1M,
9+
};
710

811
/// env 操作互斥(防并行测试互相覆盖 SILICONFLOW_PRICE_PER_1M_TOKENS)。
912
static ENV_LOCK: Mutex<()> = Mutex::new(());
@@ -67,8 +70,46 @@ fn estimate_for_content_composes() {
6770
with_env_locked(|| {
6871
std::env::set_var("SILICONFLOW_PRICE_PER_1M_TOKENS", "10");
6972
let est = estimate_for_content(5000);
70-
assert_eq!(est.est_tokens, 5000);
71-
assert!((est.est_cost_yuan - 0.05).abs() < 1e-9);
73+
// F1 修复:预估含输出 token(输入 ×2,重写型任务保守上界)
74+
assert_eq!(est.est_tokens, 10_000);
75+
assert!((est.est_cost_yuan - 0.1).abs() < 1e-9);
7276
assert_eq!(est.price_per_1m, 10.0);
77+
assert!(est.price_known);
78+
});
79+
}
80+
81+
/// F1 修复(2026-08-21):模型→单价映射表——已知模型按表取价、未知模型
82+
/// 回退 0 + price_known=false(前端显示"费用可能不准确"警告,不静默)。
83+
#[test]
84+
fn price_model_mapping_known_and_unknown() {
85+
with_env_locked(|| {
86+
std::env::remove_var("SILICONFLOW_PRICE_PER_1M_TOKENS");
87+
// 已知免费档模型 → 单价 0 且已知
88+
let (p, known) = price_for_model("deepseek-ai/DeepSeek-R1-0528-Qwen3-8B");
89+
assert_eq!(p, 0.0);
90+
assert!(known);
91+
// 未知模型 → 单价 0 + 未知标记
92+
let (p2, known2) = price_for_model("unknown/model");
93+
assert_eq!(p2, 0.0);
94+
assert!(!known2);
95+
// env 整体覆盖优先于映射表(开发路径)
96+
std::env::set_var("SILICONFLOW_PRICE_PER_1M_TOKENS", "3.5");
97+
let (p3, known3) = price_for_model("unknown/model");
98+
assert!((p3 - 3.5).abs() < 1e-9);
99+
assert!(known3);
100+
});
101+
}
102+
103+
/// F1 修复:按模型估算——已知免费档 ¥0、未知模型带警告标记。
104+
#[test]
105+
fn estimate_for_content_model_respects_mapping() {
106+
with_env_locked(|| {
107+
std::env::remove_var("SILICONFLOW_PRICE_PER_1M_TOKENS");
108+
let free = estimate_for_content_model(1000, "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B");
109+
assert!(free.price_known);
110+
assert_eq!(free.est_cost_yuan, 0.0);
111+
let unknown = estimate_for_content_model(1000, "some/model");
112+
assert!(!unknown.price_known, "未知模型必须标记警告");
113+
assert_eq!(unknown.est_cost_yuan, 0.0);
73114
});
74115
}

‎app/src-tauri/src/commands_ai_enrich.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::sync::atomic::Ordering;
1616
use tauri::State;
1717

1818
use crate::ai_client::AiClient;
19-
use crate::ai_cost::estimate_for_content;
19+
use crate::ai_cost::estimate_for_content_model;
2020
use crate::ai_enrich_protocol::{AiEnrichBlock, AiEnrichKind, AiEnrichRequest, AiEnrichResponse};
2121
use crate::ai_mock::AiMockAdapter;
2222
use crate::ai_note_enrich::AiNoteEnrichAdapter;
@@ -60,10 +60,11 @@ pub fn ai_enrich_estimate(
6060
let kinds = normalize_kinds(selected_kinds)?;
6161
let note = get_note(state.inner(), note_id)?;
6262
// 预估 = 笔记正文 + 子项提示词开销(每子项约 80 字符的系统说明——
63-
// 保守上界,与 ai_cost 字符→token 同口径)
63+
// 保守上界,与 ai_cost 字符→token 同口径;F1:按模型映射单价 + 输出 token)
6464
let chars = note.content.chars().count() + kinds.len() * 80;
6565
let remember = state.ai_settings.lock().map(|s| s.remember_cost_choice).unwrap_or(false);
66-
Ok(RefineEstimateView { estimate: estimate_for_content(chars), remember_cost_choice: remember })
66+
let model = state.ai_settings.lock().map(|s| s.model.clone()).unwrap_or_default();
67+
Ok(RefineEstimateView { estimate: estimate_for_content_model(chars, &model), remember_cost_choice: remember })
6768
}
6869

6970
/// 启动知识补充异步任务(授权红线 + 密钥解析 + 后台切片批量补充)。

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex};
1818
use tauri::{Emitter, State};
1919

2020
use crate::ai_client::AiClient;
21-
use crate::ai_cost::{estimate_for_content, CostEstimate};
21+
use crate::ai_cost::{estimate_for_content_model, CostEstimate};
2222
use crate::ai_mock::AiMockAdapter;
2323
use crate::ai_note_refine::AiNoteRefineAdapter;
2424
use crate::ai_refine_protocol::AiRefineRequest;
@@ -78,7 +78,8 @@ pub struct RefineEstimateView {
7878
pub remember_cost_choice: bool,
7979
}
8080

81-
/// 成本预估(REQ-143 基础版:按转写+OCR 字符数估算 token/费用——保守上界)。
81+
/// 成本预估(REQ-143 + F1 修复:按模型映射单价、预估含输出 token——
82+
/// 切付费模型后费用不再显示 ¥0,消灭成本失真)。
8283
#[tauri::command]
8384
pub fn ai_refine_estimate(state: State<'_, AppState>, session_id: i64) -> Result<RefineEstimateView, String> {
8485
if session_id <= 0 {
@@ -93,7 +94,8 @@ pub fn ai_refine_estimate(state: State<'_, AppState>, session_id: i64) -> Result
9394
.lock()
9495
.map(|s| s.remember_cost_choice)
9596
.unwrap_or(false);
96-
Ok(RefineEstimateView { estimate: estimate_for_content(chars), remember_cost_choice: remember })
97+
let model = state.ai_settings.lock().map(|s| s.model.clone()).unwrap_or_default();
98+
Ok(RefineEstimateView { estimate: estimate_for_content_model(chars, &model), remember_cost_choice: remember })
9799
}
98100

99101
/// 启动 AI 精修异步任务(授权红线 + 密钥解析 + 后台切片逐片精修)。

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
281281
预估 token:<strong>{est.estTokens}</strong> · 预估费用:<strong>¥{est.estCostYuan.toFixed(4)}</strong>
282282
{est.pricePer1m > 0 && <span style={{ color: "#6b7280" }}>(单价 ¥{est.pricePer1m}/1M token)</span>}
283283
{est.pricePer1m === 0 && <span style={{ color: "#0d9488" }}>(当前模型免费档 ¥0)</span>}
284+
{est.pricePer1m === 0 && est.priceKnown === false && <span style={{ color: "#d97706" }}>(该模型单价未登记,费用可能不准确)</span>}
284285
</div>
285286
{balance && (
286287
<div style={{ color: balance.lowBalanceWarning ? "#dc2626" : "#374151" }}>

‎app/src/components/EnrichPanel.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export default function EnrichPanel({ noteId, onUpdated }: { noteId: number; onU
312312
<div>
313313
预估 token:<strong>{est.estTokens}</strong> · 预估费用:<strong>¥{est.estCostYuan.toFixed(4)}</strong>
314314
{est.estCostYuan === 0 && <span style={{ color: "#0d9488" }}>(当前模型免费档 ¥0)</span>}
315+
{est.estCostYuan === 0 && est.priceKnown === false && <span style={{ color: "#d97706" }}>(该模型单价未登记,费用可能不准确)</span>}
315316
</div>
316317
{balance && (
317318
<div style={{ color: balance.lowBalanceWarning ? "#dc2626" : "#374151" }}>

‎app/src/types.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,11 +718,13 @@ export interface AiRefineResult {
718718
model: string;
719719
}
720720

721-
/** 成本预估(Rust CostEstimate;camelCase 契约) */
721+
/** 成本预估(Rust CostEstimate;camelCase 契约;priceKnown=false → 单价未登记警告) */
722722
export interface CostEstimate {
723723
estTokens: number;
724724
estCostYuan: number;
725725
pricePer1m: number;
726+
/** 该模型单价是否已登记(false → 显示"费用可能不准确"警告) */
727+
priceKnown: boolean;
726728
}
727729

728730
/** 精修成本预估视图(Rust RefineEstimateView;camelCase 契约) */

0 commit comments

Comments
 (0)