Skip to content

Commit 4955792

Browse files
feat:接入OpenRouter token计价 (#3)
* feat:接入OpenRouter token计价 * feat:同步Token计价设计稿 * fix: serialize OpenRouter pricing cache refresh Agent-Logs-Url: https://github.com/JialinLiu-codedance/ai-usage/sessions/675620e3-7a39-4cfe-bc34-3fbc969d1247 Co-authored-by: JialinLiu-codedance <218696320+JialinLiu-codedance@users.noreply.github.com> * chore: simplify openrouter refresh error handling Agent-Logs-Url: https://github.com/JialinLiu-codedance/ai-usage/sessions/675620e3-7a39-4cfe-bc34-3fbc969d1247 Co-authored-by: JialinLiu-codedance <218696320+JialinLiu-codedance@users.noreply.github.com> * chore: remove unintended generated schema file Agent-Logs-Url: https://github.com/JialinLiu-codedance/ai-usage/sessions/675620e3-7a39-4cfe-bc34-3fbc969d1247 Co-authored-by: JialinLiu-codedance <218696320+JialinLiu-codedance@users.noreply.github.com> * fix:避免OpenRouter并发刷新误报警告 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JialinLiu-codedance <218696320+JialinLiu-codedance@users.noreply.github.com>
1 parent 52719ec commit 4955792

16 files changed

Lines changed: 3864 additions & 379 deletions

UI.pen

Lines changed: 2210 additions & 359 deletions
Large diffs are not rendered by default.

src-tauri/src/commands.rs

Lines changed: 215 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::{
1313
CUSTOM_USAGE_WINDOW_DAYS, PROVIDER_ANTHROPIC, PROVIDER_COPILOT, PROVIDER_GLM,
1414
PROVIDER_KIMI, PROVIDER_MINIMAX, PROVIDER_OPENAI,
1515
},
16-
notifications, oauth, pr_kpi, provider, secrets, settings,
16+
notifications, oauth, openrouter_pricing, pr_kpi, provider, secrets, settings,
1717
state::StateStore,
1818
storage,
1919
};
@@ -31,13 +31,18 @@ use tauri_plugin_autostart::ManagerExt;
3131
const SNAPSHOTS_FILE: &str = "snapshots.json";
3232
const TOKEN_USAGE_CACHE_FILE: &str = "local-token-usage-cache.json";
3333
const TOKEN_USAGE_CACHE_UPDATED_EVENT: &str = "local-token-usage-cache-updated";
34+
const OPENROUTER_PRICING_CACHE_FILE: &str = "openrouter-pricing-cache.json";
35+
const OPENROUTER_PRICING_CACHE_MAX_AGE_HOURS: i64 = 24;
36+
const OPENROUTER_PRICING_REFRESH_IN_PROGRESS_ERROR: &str =
37+
"OpenRouter 价格缓存正在刷新,请稍后再试";
3438
const GIT_USAGE_CACHE_FILE: &str = "git-usage-cache.json";
3539
const GIT_USAGE_CACHE_UPDATED_EVENT: &str = "git-usage-cache-updated";
3640
const GIT_BRANCH_MANAGEMENT_CACHE_FILE: &str = "git-branch-management-cache.json";
3741
const GIT_BRANCH_MANAGEMENT_CACHE_UPDATED_EVENT: &str = "git-branch-management-cache-updated";
3842
const PR_KPI_CACHE_FILE: &str = "pr-kpi-cache.json";
3943
const PR_KPI_CACHE_UPDATED_EVENT: &str = "pr-kpi-cache-updated";
4044
static TOKEN_USAGE_CACHE_REFRESHING: AtomicBool = AtomicBool::new(false);
45+
static OPENROUTER_PRICING_CACHE_REFRESHING: AtomicBool = AtomicBool::new(false);
4146
static GIT_USAGE_CACHE_REFRESHING: AtomicBool = AtomicBool::new(false);
4247
static GIT_BRANCH_MANAGEMENT_CACHE_REFRESHING: AtomicBool = AtomicBool::new(false);
4348
static PR_KPI_CACHE_REFRESHING: AtomicBool = AtomicBool::new(false);
@@ -206,6 +211,33 @@ pub fn save_settings(app: AppHandle, input: SaveSettingsInput) -> Result<AppSett
206211
get_settings(app)
207212
}
208213

214+
#[tauri::command]
215+
pub fn save_openrouter_model_override(
216+
app: AppHandle,
217+
local_model: String,
218+
openrouter_model_id: Option<String>,
219+
) -> Result<AppSettings, String> {
220+
let settings = settings::save_openrouter_model_override(&app, local_model, openrouter_model_id)?;
221+
let _ = app.emit(TOKEN_USAGE_CACHE_UPDATED_EVENT, ());
222+
Ok(settings)
223+
}
224+
225+
#[tauri::command]
226+
pub async fn get_openrouter_model_ids(app: AppHandle) -> Result<Vec<String>, String> {
227+
if let Ok(Some(catalog)) = read_openrouter_pricing_cache(&app) {
228+
if openrouter_pricing_cache_is_stale(&catalog) {
229+
start_openrouter_pricing_cache_refresh(app.clone());
230+
}
231+
return Ok(catalog.model_ids());
232+
}
233+
234+
match refresh_openrouter_pricing_cache(app).await {
235+
Ok(catalog) => Ok(catalog.model_ids()),
236+
Err(error) if is_openrouter_pricing_refresh_in_progress(&error) => Ok(Vec::new()),
237+
Err(error) => Err(error),
238+
}
239+
}
240+
209241
#[tauri::command]
210242
pub async fn get_local_proxy_settings(
211243
app: AppHandle,
@@ -657,13 +689,19 @@ pub fn get_local_token_usage(
657689
}
658690

659691
if let Some(cache) = cache.filter(|cache| cache.covers_custom_range(start_date, end_date)) {
660-
return Ok(cache.custom_report(start_date, end_date));
692+
return Ok(apply_cached_openrouter_pricing(
693+
&app,
694+
cache.custom_report(start_date, end_date),
695+
));
661696
}
662697

663-
return Ok(local_usage::pending_custom_report(
664-
start_date,
665-
end_date,
666-
Some("Token 用量缓存正在后台生成,完成后会自动更新".into()),
698+
return Ok(apply_cached_openrouter_pricing(
699+
&app,
700+
local_usage::pending_custom_report(
701+
start_date,
702+
end_date,
703+
Some("Token 用量缓存正在后台生成,完成后会自动更新".into()),
704+
),
667705
));
668706
}
669707

@@ -680,12 +718,15 @@ pub fn get_local_token_usage(
680718
}
681719

682720
if let Some(cache) = cache {
683-
return Ok(cache.report(range));
721+
return Ok(apply_cached_openrouter_pricing(&app, cache.report(range)));
684722
}
685723

686-
Ok(local_usage::pending_report(
687-
range,
688-
Some("Token 用量缓存正在后台生成,完成后会自动更新".into()),
724+
Ok(apply_cached_openrouter_pricing(
725+
&app,
726+
local_usage::pending_report(
727+
range,
728+
Some("Token 用量缓存正在后台生成,完成后会自动更新".into()),
729+
),
689730
))
690731
}
691732

@@ -700,18 +741,22 @@ pub async fn refresh_local_token_usage(
700741
end_date,
701742
} = resolved
702743
{
703-
let cache = refresh_local_token_usage_cache(app, true).await?;
744+
let cache = refresh_local_token_usage_cache(app.clone(), true).await?;
704745
if !cache.covers_custom_range(start_date, end_date) {
705746
return Err("Token 用量缓存刷新后仍未准备好".into());
706747
}
707-
return Ok(cache.custom_report(start_date, end_date));
748+
return Ok(apply_refreshed_openrouter_pricing(
749+
app,
750+
cache.custom_report(start_date, end_date),
751+
)
752+
.await);
708753
}
709754

710755
let ResolvedUsageRange::Preset(range) = resolved else {
711756
unreachable!("custom usage range returned above");
712757
};
713-
let cache = refresh_local_token_usage_cache(app, true).await?;
714-
Ok(cache.report(range))
758+
let cache = refresh_local_token_usage_cache(app.clone(), true).await?;
759+
Ok(apply_refreshed_openrouter_pricing(app, cache.report(range)).await)
715760
}
716761

717762
#[tauri::command]
@@ -1208,6 +1253,21 @@ fn start_local_token_usage_cache_refresh(app: AppHandle) {
12081253
});
12091254
}
12101255

1256+
fn start_openrouter_pricing_cache_refresh(app: AppHandle) {
1257+
tauri::async_runtime::spawn(async move {
1258+
match refresh_openrouter_pricing_cache(app.clone()).await {
1259+
Ok(_) => {
1260+
let _ = app.emit(TOKEN_USAGE_CACHE_UPDATED_EVENT, ());
1261+
}
1262+
Err(error) => {
1263+
if !is_openrouter_pricing_refresh_in_progress(&error) {
1264+
eprintln!("OpenRouter pricing cache refresh failed: {error}");
1265+
}
1266+
}
1267+
}
1268+
});
1269+
}
1270+
12111271
fn start_git_usage_cache_refresh(app: AppHandle) {
12121272
tauri::async_runtime::spawn(async move {
12131273
match refresh_git_usage_cache(app, true).await {
@@ -1256,6 +1316,45 @@ async fn refresh_local_token_usage_cache(
12561316
Ok(cache)
12571317
}
12581318

1319+
async fn apply_refreshed_openrouter_pricing(
1320+
app: AppHandle,
1321+
report: LocalTokenUsageReport,
1322+
) -> LocalTokenUsageReport {
1323+
let model_overrides = settings::load_settings(&app)
1324+
.map(|settings| settings.openrouter_model_overrides)
1325+
.unwrap_or_default();
1326+
match refresh_openrouter_pricing_cache(app.clone()).await {
1327+
Ok(catalog) => openrouter_pricing::apply_pricing_to_report_with_overrides(
1328+
report,
1329+
Some(&catalog),
1330+
None,
1331+
&model_overrides,
1332+
),
1333+
Err(error) => {
1334+
let cached = read_openrouter_pricing_cache(&app).ok().flatten();
1335+
let warning = openrouter_pricing_refresh_warning(&error);
1336+
openrouter_pricing::apply_pricing_to_report_with_overrides(
1337+
report,
1338+
cached.as_ref(),
1339+
warning,
1340+
&model_overrides,
1341+
)
1342+
}
1343+
}
1344+
}
1345+
1346+
async fn refresh_openrouter_pricing_cache(
1347+
app: AppHandle,
1348+
) -> Result<openrouter_pricing::OpenRouterPricingCatalog, String> {
1349+
let _guard = claim_openrouter_pricing_refresh()?;
1350+
let catalog =
1351+
tauri::async_runtime::spawn_blocking(openrouter_pricing::fetch_openrouter_pricing_catalog)
1352+
.await
1353+
.map_err(|error| format!("OpenRouter 价格缓存任务失败: {error}"))??;
1354+
storage::write_json(&app, OPENROUTER_PRICING_CACHE_FILE, &catalog)?;
1355+
Ok(catalog)
1356+
}
1357+
12591358
async fn refresh_git_usage_cache(
12601359
app: AppHandle,
12611360
emit_update: bool,
@@ -1366,6 +1465,34 @@ fn claim_local_token_usage_refresh() -> Result<LocalTokenUsageRefreshGuard, Stri
13661465
Ok(LocalTokenUsageRefreshGuard)
13671466
}
13681467

1468+
#[derive(Debug)]
1469+
struct OpenRouterPricingRefreshGuard;
1470+
1471+
impl Drop for OpenRouterPricingRefreshGuard {
1472+
fn drop(&mut self) {
1473+
OPENROUTER_PRICING_CACHE_REFRESHING.store(false, Ordering::Release);
1474+
}
1475+
}
1476+
1477+
fn claim_openrouter_pricing_refresh() -> Result<OpenRouterPricingRefreshGuard, String> {
1478+
if OPENROUTER_PRICING_CACHE_REFRESHING.swap(true, Ordering::AcqRel) {
1479+
return Err(OPENROUTER_PRICING_REFRESH_IN_PROGRESS_ERROR.into());
1480+
}
1481+
Ok(OpenRouterPricingRefreshGuard)
1482+
}
1483+
1484+
fn is_openrouter_pricing_refresh_in_progress(error: &str) -> bool {
1485+
error == OPENROUTER_PRICING_REFRESH_IN_PROGRESS_ERROR
1486+
}
1487+
1488+
fn openrouter_pricing_refresh_warning(error: &str) -> Option<String> {
1489+
if is_openrouter_pricing_refresh_in_progress(error) {
1490+
None
1491+
} else {
1492+
Some(format!("OpenRouter 价格刷新失败: {error}"))
1493+
}
1494+
}
1495+
13691496
#[derive(Debug)]
13701497
struct GitUsageRefreshGuard;
13711498

@@ -1713,6 +1840,49 @@ fn read_local_token_usage_cache(
17131840
storage::read_json::<local_usage::LocalTokenUsageCache>(app, TOKEN_USAGE_CACHE_FILE)
17141841
}
17151842

1843+
fn read_openrouter_pricing_cache(
1844+
app: &AppHandle,
1845+
) -> Result<Option<openrouter_pricing::OpenRouterPricingCatalog>, String> {
1846+
storage::read_json::<openrouter_pricing::OpenRouterPricingCatalog>(
1847+
app,
1848+
OPENROUTER_PRICING_CACHE_FILE,
1849+
)
1850+
}
1851+
1852+
fn apply_cached_openrouter_pricing(
1853+
app: &AppHandle,
1854+
report: LocalTokenUsageReport,
1855+
) -> LocalTokenUsageReport {
1856+
let model_overrides = settings::load_settings(app)
1857+
.map(|settings| settings.openrouter_model_overrides)
1858+
.unwrap_or_default();
1859+
match read_openrouter_pricing_cache(app) {
1860+
Ok(Some(catalog)) => {
1861+
if openrouter_pricing_cache_is_stale(&catalog) {
1862+
start_openrouter_pricing_cache_refresh(app.clone());
1863+
}
1864+
openrouter_pricing::apply_pricing_to_report_with_overrides(
1865+
report,
1866+
Some(&catalog),
1867+
None,
1868+
&model_overrides,
1869+
)
1870+
}
1871+
Ok(None) => {
1872+
start_openrouter_pricing_cache_refresh(app.clone());
1873+
openrouter_pricing::apply_pricing_to_report(report, None, None)
1874+
}
1875+
Err(error) => {
1876+
start_openrouter_pricing_cache_refresh(app.clone());
1877+
openrouter_pricing::apply_pricing_to_report(
1878+
report,
1879+
None,
1880+
Some(format!("读取 OpenRouter 价格缓存失败: {error}")),
1881+
)
1882+
}
1883+
}
1884+
}
1885+
17161886
fn read_git_usage_cache(app: &AppHandle) -> Result<Option<git_usage::GitUsageCache>, String> {
17171887
storage::read_json::<git_usage::GitUsageCache>(app, GIT_USAGE_CACHE_FILE)
17181888
}
@@ -1737,6 +1907,10 @@ fn local_token_usage_cache_is_stale(
17371907
(Utc::now() - cache.generated_at).num_minutes() >= max_age_minutes
17381908
}
17391909

1910+
fn openrouter_pricing_cache_is_stale(cache: &openrouter_pricing::OpenRouterPricingCatalog) -> bool {
1911+
(Utc::now() - cache.generated_at).num_hours() >= OPENROUTER_PRICING_CACHE_MAX_AGE_HOURS
1912+
}
1913+
17401914
fn git_usage_cache_is_stale(
17411915
cache: &git_usage::GitUsageCache,
17421916
max_age_minutes: i64,
@@ -2283,6 +2457,33 @@ mod tests {
22832457
assert!(claim_git_usage_refresh().is_ok());
22842458
}
22852459

2460+
#[test]
2461+
fn openrouter_pricing_refresh_guard_rejects_parallel_refreshes_and_releases() {
2462+
let guard = claim_openrouter_pricing_refresh().unwrap();
2463+
2464+
let error = claim_openrouter_pricing_refresh().unwrap_err();
2465+
2466+
assert_eq!(error, OPENROUTER_PRICING_REFRESH_IN_PROGRESS_ERROR);
2467+
drop(guard);
2468+
assert!(claim_openrouter_pricing_refresh().is_ok());
2469+
}
2470+
2471+
#[test]
2472+
fn openrouter_pricing_refresh_in_progress_does_not_create_warning() {
2473+
assert_eq!(
2474+
openrouter_pricing_refresh_warning(OPENROUTER_PRICING_REFRESH_IN_PROGRESS_ERROR),
2475+
None
2476+
);
2477+
}
2478+
2479+
#[test]
2480+
fn openrouter_pricing_refresh_errors_still_create_warning() {
2481+
assert_eq!(
2482+
openrouter_pricing_refresh_warning("network failed"),
2483+
Some("OpenRouter 价格刷新失败: network failed".to_string())
2484+
);
2485+
}
2486+
22862487
#[test]
22872488
fn git_usage_cache_is_stale_when_root_path_changes() {
22882489
let now = chrono::Utc::now();

0 commit comments

Comments
 (0)