优化: token分项显示(in/cache/out/reasoning)+ 详情面板 + base前置
token分项(各计费不同,不显 total):df-ai 解析 provider cache/reasoning(openai_compat prompt_cache_hit/miss/reasoning_tokens + anthropic cache_read/creation)+ TokenUsage 加字段(全构造点)+ AiMessage/AiCompleted/DB V39(ai_messages 加 cache_hit/miss/reasoning 列)+ message_repo 映射(持久化)+ 前端 MessageList 显 in·cache·out·reason(in=cache_miss 全价,reasoning 有才显)+ 点击 token 弹详情面板(完整 usage+缓存命中率+model)+ df-miniapp 同步 base前置(提升 prompt cache 命中率):chat.rs aug 拼 base 后(4处)+ knowledge_inject 知识拼 base 后(固定 base 前缀,cache 命中) 附修:replace_conversation 原 13 列 INSERT 丢消息级 token → 改 18 列
This commit is contained in:
@@ -42,25 +42,25 @@ impl ContentPart {
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||
@@ -315,6 +315,11 @@ mod tests {
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
timestamp: None,
|
||||
};
|
||||
assert_eq!(m.content, "字面量构造");
|
||||
@@ -367,6 +372,11 @@ mod tests {
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: Some("thinking process".to_string()),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
@@ -422,6 +432,11 @@ mod tests {
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
@@ -469,7 +484,7 @@ mod tests {
|
||||
let resp = CompletionResponse {
|
||||
text: "ok".to_string(),
|
||||
model: "r1".to_string(),
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() },
|
||||
tool_calls: None,
|
||||
reasoning_content: Some("r1 thought".to_string()),
|
||||
};
|
||||
|
||||
@@ -124,6 +124,20 @@ pub struct ChatMessage {
|
||||
/// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens: Option<u32>,
|
||||
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
|
||||
/// token 分项显示(2026-08-02):前端 in=miss(全价真实)+ cache=hit(命中) 分计费展示。
|
||||
/// 老 JSON 反序列化为 None(向前兼容);非 cache provider 恒为 0。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_hit_tokens: Option<u32>,
|
||||
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)。
|
||||
/// 前端 in 显示用此字段(非 prompt_tokens 总,避免掩盖命中比例)。
|
||||
/// 老 JSON 反序列化为 None(向前兼容)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
|
||||
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
|
||||
@@ -287,11 +301,32 @@ pub struct CompletionResponse {
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
///
|
||||
/// 分项字段(token 分项显示 + 详情面板,2026-08-02):
|
||||
/// - `prompt_tokens`:输入总(= cache_hit + cache_miss,兼容老链路保留;前端不再单独展示,
|
||||
/// 改用 cache_miss 作 in 真实全价消耗)。
|
||||
/// - `completion_tokens`:输出。
|
||||
/// - `total_tokens`:总计(各 provider 计费不同,相加无意义,前端不显;保留供老链路/日志)。
|
||||
/// - `prompt_cache_hit_tokens`:缓存命中(低价,deepseek/anthropic cache_read)。
|
||||
/// - `prompt_cache_miss_tokens`:未命中(全价真实输入,deepseek/openai 扩展字段)。
|
||||
/// - `reasoning_tokens`:思考(deepseek-reasoner/o1 隐藏输出 token)。
|
||||
///
|
||||
/// 不同 provider 字段名不同,serde default 兜底(无则 0),向前兼容老响应。
|
||||
/// 构造点全用 `..Default::default()` 补缺,详见各 provider 解析点。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read_input_tokens)
|
||||
#[serde(default)]
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)
|
||||
#[serde(default)]
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u32,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
|
||||
@@ -588,6 +588,10 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
prompt_tokens: resp.usage.input_tokens,
|
||||
completion_tokens: resp.usage.output_tokens,
|
||||
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
|
||||
// anthropic cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价
|
||||
prompt_cache_hit_tokens: resp.usage.cache_read_input_tokens,
|
||||
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
|
||||
reasoning_tokens: 0,
|
||||
};
|
||||
AttemptOutcome::Ok(CompletionResponse {
|
||||
text,
|
||||
|
||||
@@ -75,6 +75,16 @@ pub(crate) struct AnthropicContentBlock {
|
||||
pub(crate) struct AnthropicUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
|
||||
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
|
||||
/// 非 cache 场景无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub cache_creation_input_tokens: u32,
|
||||
/// Anthropic prompt caching 扩展:cache 命中读取 token(低价)。
|
||||
/// 映射到 TokenUsage.prompt_cache_hit_tokens。
|
||||
/// 非 cache 场景无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub cache_read_input_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -114,7 +124,8 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match ty {
|
||||
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)
|
||||
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)。
|
||||
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
|
||||
"message_start" => {
|
||||
if let Some(inp) = v
|
||||
.get("message")
|
||||
@@ -122,10 +133,22 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
.and_then(|u| u.get("input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
{
|
||||
let u_obj = v.get("message").and_then(|m| m.get("usage"));
|
||||
let cache_read = u_obj
|
||||
.and_then(|u| u.get("cache_read_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let cache_creation = u_obj
|
||||
.and_then(|u| u.get("cache_creation_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: inp as u32,
|
||||
completion_tokens: 0,
|
||||
total_tokens: inp as u32,
|
||||
prompt_cache_hit_tokens: cache_read,
|
||||
prompt_cache_miss_tokens: cache_creation,
|
||||
reasoning_tokens: 0,
|
||||
});
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
@@ -134,7 +157,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
"message_delta" => {
|
||||
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
|
||||
let acc = usage_accum
|
||||
.get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
.get_or_insert(TokenUsage::default());
|
||||
acc.completion_tokens = out as u32;
|
||||
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
|
||||
}
|
||||
|
||||
@@ -1603,11 +1603,7 @@ mod tests {
|
||||
Ok(crate::provider::CompletionResponse {
|
||||
text: self.response_text.clone(),
|
||||
model: "mock".to_string(),
|
||||
usage: crate::provider::TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
usage: crate::provider::TokenUsage::default(),
|
||||
tool_calls: None,
|
||||
reasoning_content: None,
|
||||
})
|
||||
|
||||
@@ -535,11 +535,10 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
}).unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
|
||||
prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
|
||||
reasoning_tokens: u.reasoning_tokens,
|
||||
}).unwrap_or_default();
|
||||
AttemptOutcome::Ok(CompletionResponse {
|
||||
text,
|
||||
model: body.model,
|
||||
|
||||
@@ -102,6 +102,18 @@ pub(crate) struct OpenAiUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner prompt_cache_hit_tokens)。
|
||||
/// OpenAI 官方(o1 等)无此字段 → serde default 0。其他 OpenAI 兼容网关若支持 cache 也用此名。
|
||||
#[serde(default)]
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。
|
||||
/// OpenAI 官方无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
|
||||
/// 非 reasoning 模型无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u32,
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
@@ -200,6 +212,9 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
|
||||
prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
|
||||
reasoning_tokens: u.reasoning_tokens,
|
||||
});
|
||||
}
|
||||
if let Some(choice) = chunk.choices.into_iter().next() {
|
||||
|
||||
@@ -43,6 +43,9 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
|
||||
created_at: row.get("created_at")?,
|
||||
prompt_tokens: row.get("prompt_tokens")?,
|
||||
completion_tokens: row.get("completion_tokens")?,
|
||||
prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?,
|
||||
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
|
||||
reasoning_tokens: row.get("reasoning_tokens")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,8 +85,9 @@ impl AiMessageRepo {
|
||||
"INSERT OR IGNORE INTO ai_messages
|
||||
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
@@ -91,7 +95,8 @@ impl AiMessageRepo {
|
||||
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
|
||||
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
||||
rec.reasoning_content, rec.timestamp, rec.created_at,
|
||||
rec.prompt_tokens, rec.completion_tokens
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens
|
||||
])
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
@@ -116,7 +121,8 @@ impl AiMessageRepo {
|
||||
.prepare(
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens
|
||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
@@ -157,12 +163,14 @@ impl AiMessageRepo {
|
||||
let sql = if before_seq.is_some() {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens
|
||||
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
|
||||
} else {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens
|
||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
|
||||
@@ -273,19 +281,25 @@ impl AiMessageRepo {
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
// 再批量插新行(INSERT OR IGNORE 幂等,id 冲突跳过)
|
||||
// 含 token 全列(prompt/completion/cache_hit/cache_miss/reasoning,2026-08-02 对齐 insert_batch),
|
||||
// 全量重写不丢消息级 token 数据。
|
||||
if !records.is_empty() {
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT OR IGNORE INTO ai_messages
|
||||
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
prompt_tokens, completion_tokens,
|
||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
stmt.execute(params![
|
||||
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
|
||||
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
||||
rec.reasoning_content, rec.timestamp, rec.created_at
|
||||
rec.reasoning_content, rec.timestamp, rec.created_at,
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens
|
||||
])
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
@@ -356,6 +370,9 @@ mod tests {
|
||||
created_at: now_millis_str(),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +532,9 @@ mod tests {
|
||||
created_at: now.clone(),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
},
|
||||
AiMessageRecord {
|
||||
id: "new_1".into(),
|
||||
@@ -532,6 +552,9 @@ mod tests {
|
||||
created_at: now,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
},
|
||||
];
|
||||
repo.replace_conversation("conv", records).await.expect("replace");
|
||||
@@ -601,6 +624,9 @@ mod tests {
|
||||
created_at: now,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
@@ -639,6 +665,9 @@ mod tests {
|
||||
created_at: now.clone(),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
};
|
||||
repo.replace_conversation("c", vec![rec()]).await.expect("1st");
|
||||
repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 38] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 39] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -84,6 +84,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(36, migrate_v36),
|
||||
(37, migrate_v37),
|
||||
(38, migrate_v38),
|
||||
(39, migrate_v39),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -1157,6 +1158,32 @@ fn migrate_v38(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V39: ai_messages 加 prompt_cache_hit_tokens / prompt_cache_miss_tokens / reasoning_tokens 列
|
||||
///
|
||||
/// token 分项显示(2026-08-02):各 provider 计费不同(deepseek cache 命中低价/未命中全价/
|
||||
/// 输出价高/reasoning 隐藏输出),前端 in/cache/out/reason 分项展示 + 详情面板。
|
||||
/// - prompt_cache_hit_tokens:缓存命中(deepseek prompt_cache_hit / anthropic cache_read)
|
||||
/// - prompt_cache_miss_tokens:未命中全价(deepseek prompt_cache_miss / anthropic cache_creation)
|
||||
/// - reasoning_tokens:思考(deepseek-reasoner/o1 reasoning_tokens)
|
||||
/// 三列均 nullable,老消息 NULL → None(向前兼容,非 cache provider 恒 0)。
|
||||
fn migrate_v39(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ai_messages", "prompt_cache_hit_tokens") {
|
||||
conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_cache_hit_tokens INTEGER", [])?;
|
||||
tracing::info!("v39: ai_messages 加 prompt_cache_hit_tokens 列");
|
||||
}
|
||||
if !column_exists(conn, "ai_messages", "prompt_cache_miss_tokens") {
|
||||
conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_cache_miss_tokens INTEGER", [])?;
|
||||
tracing::info!("v39: ai_messages 加 prompt_cache_miss_tokens 列");
|
||||
}
|
||||
if !column_exists(conn, "ai_messages", "reasoning_tokens") {
|
||||
conn.execute("ALTER TABLE ai_messages ADD COLUMN reasoning_tokens INTEGER", [])?;
|
||||
tracing::info!("v39: ai_messages 加 reasoning_tokens 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [39])?;
|
||||
tracing::info!("迁移 v39 完成: ai_messages 加 cache/reasoning 分项 token 列");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -1178,6 +1205,9 @@ CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
created_at TEXT NOT NULL,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
prompt_cache_hit_tokens INTEGER,
|
||||
prompt_cache_miss_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
UNIQUE(conversation_id, seq)
|
||||
);
|
||||
|
||||
@@ -1438,6 +1468,9 @@ CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
created_at TEXT NOT NULL,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
prompt_cache_hit_tokens INTEGER,
|
||||
prompt_cache_miss_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
UNIQUE(conversation_id, seq)
|
||||
);
|
||||
|
||||
|
||||
@@ -426,6 +426,13 @@ pub struct AiMessageRecord {
|
||||
pub prompt_tokens: Option<u32>,
|
||||
/// 本轮 LLM 调用输出 token 用量(仅 assistant,消息级 token 持久化)。
|
||||
pub completion_tokens: Option<u32>,
|
||||
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
|
||||
/// token 分项显示(2026-08-02):V39 加列,老消息 NULL → None(向前兼容)。
|
||||
pub prompt_cache_hit_tokens: Option<u32>,
|
||||
/// 未命中 token(全价真实输入)。前端 in 显示用此字段(非 prompt_tokens 总)。
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user