feat(usage): support importing model pricing from models.dev (#4079)

* feat(usage): support importing model pricing from models.dev

Add an "Import from models.dev" button to the Add Pricing panel that
fetches https://models.dev/api.json, lists priced models sorted by
release date (newest 50 by default, full-text search across ~4800),
and bulk-imports the selected entries through the same
update_model_pricing command as manual entry.

- Normalize imported model IDs to match the backend's
  clean_model_id_for_pricing rules (strip vendor prefix, lowercase,
  truncate ':' suffix, map '@' to '-', drop the [1m] marker) so the
  stored rows actually match cost-attribution lookups
- Dedupe selections that collapse to the same model_id and report
  skipped duplicates in the success toast
- Invalidate usage queries on settled (not just success) so partial
  import failures still refresh the pricing list
- Keep ESC inside the picker's search input from closing the dialog
  and discarding the selection
- Add i18n keys for zh/en/zh-TW/ja and unit tests for the
  normalization, price formatting and flattening logic

Fixes #4017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(usage): match scoped cost backfill against raw model aliases

The scoped backfill selected zero-cost rows via exact SQL string match,
but log columns store raw model strings (route prefixes, :free variants,
date suffixes), so alias rows were skipped until the next full backfill
on startup. Filter rows in Rust with the same model_pricing_candidates
normalization used by the pricing lookup; pricing decision logic is
untouched. Pre-existing gap from schema v11, surfaced by bulk import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(usage): restrict models.dev pricing import to a single model

Each update_model_pricing call triggers a backfill pass that loads
every zero-cost usage row before filtering by model, so bulk-importing
N entries scaled as selectedModels x allZeroCostLogs full scans.
Re-importing pricing is rare, so drop the batch path instead of
optimizing it: the picker is now single-select, one import runs exactly
one update_model_pricing call and one backfill pass. This also removes
the normalized-ID dedup logic and the useImportModelPricing hook in
favor of the existing useUpdateModelPricing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Xinyu Kuo
2026-06-16 22:01:56 +08:00
committed by GitHub
parent 81d6002ace
commit 0bb3b7515a
8 changed files with 778 additions and 16 deletions
+91 -15
View File
@@ -1732,23 +1732,20 @@ impl Database {
OR cache_read_tokens > 0 OR cache_creation_tokens > 0)";
let mut logs = {
match only_model_id {
Some(model) => {
let sql = format!(
"{BASE_SQL} AND (model = ?1 OR request_model = ?1 OR pricing_model = ?1)"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([model], row_to_request_log_detail)?;
rows.collect::<Result<Vec<_>, _>>()?
}
None => {
let mut stmt = conn.prepare(BASE_SQL)?;
let rows = stmt.query_map([], row_to_request_log_detail)?;
rows.collect::<Result<Vec<_>, _>>()?
}
}
let mut stmt = conn.prepare(BASE_SQL)?;
let rows = stmt.query_map([], row_to_request_log_detail)?;
rows.collect::<Result<Vec<_>, _>>()?
};
// 精准回填的行筛选必须与查价层共用 candidates 归一化:SQL 精确匹配会漏掉
// 以原始别名落库的行(如 openrouter/anthropic/claude-sonnet-4.5:free),
// 这些行查价时能归一化命中新定价,却在筛选层被挡掉,导致导入定价后
// 历史成本要等下次全量回填才更新。误纳无害——查不到价的行会被跳过。
if let Some(model_id) = only_model_id {
let target = model_pricing_candidates(model_id);
logs.retain(|log| log_pricing_scope_matches(log, &target));
}
if logs.is_empty() {
return Ok(0);
}
@@ -1966,6 +1963,30 @@ pub(crate) fn find_model_pricing_row(
Ok(None)
}
/// 精准回填的行筛选:log 的任一模型字段归一化后与目标模型的 candidates 相交,
/// 或可按查价层的前缀规则命中目标,即视为相关。镜像 find_model_pricing_row 的
/// 匹配语义,宁可误纳(后续查价会兜底)不可漏筛。
fn log_pricing_scope_matches(log: &RequestLogDetail, target_candidates: &[String]) -> bool {
[
Some(log.model.as_str()),
log.request_model.as_deref(),
log.pricing_model.as_deref(),
]
.into_iter()
.flatten()
.any(|field| {
model_pricing_candidates(field).iter().any(|candidate| {
target_candidates.iter().any(|target| {
target == candidate
|| (should_try_pricing_prefix_match(candidate)
&& target
.strip_prefix(candidate.as_str())
.is_some_and(|rest| rest.starts_with('-')))
})
})
})
}
pub(crate) fn is_placeholder_pricing_model(model_id: &str) -> bool {
let normalized = model_id.trim().to_ascii_lowercase();
normalized.is_empty() || matches!(normalized.as_str(), "unknown" | "null" | "none")
@@ -2652,6 +2673,61 @@ mod tests {
Ok(())
}
#[test]
fn test_scoped_backfill_matches_raw_alias_rows() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
// 代理日志按上游原文落库:带路由前缀和 :free 后缀的别名形式。
// 精准回填的筛选必须归一化后匹配,否则这类行要等全量回填才更新。
insert_usage_log(
&conn,
"openrouter-alias-zero-cost",
"claude",
"provider-1",
"openrouter/moonshot/kimi-k2-novel:free",
"proxy",
1000,
1_000_000,
0,
0,
0,
200,
"0",
)?;
}
// 定价缺失时不应回填
assert_eq!(db.backfill_missing_usage_costs()?, 0);
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')",
[],
)?;
}
// 按归一化 ID 精准回填,应命中以原始别名落库的行
assert_eq!(
db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?,
1
);
let conn = lock_conn!(db.conn);
let total_cost: String = conn.query_row(
"SELECT total_cost_usd
FROM proxy_request_logs WHERE request_id = 'openrouter-alias-zero-cost'",
[],
|row| row.get(0),
)?;
assert_eq!(total_cost, "0.600000");
Ok(())
}
#[test]
fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> {
let db = Database::memory()?;