@@ -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;
3131const SNAPSHOTS_FILE : & str = "snapshots.json" ;
3232const TOKEN_USAGE_CACHE_FILE : & str = "local-token-usage-cache.json" ;
3333const 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 价格缓存正在刷新,请稍后再试" ;
3438const GIT_USAGE_CACHE_FILE : & str = "git-usage-cache.json" ;
3539const GIT_USAGE_CACHE_UPDATED_EVENT : & str = "git-usage-cache-updated" ;
3640const GIT_BRANCH_MANAGEMENT_CACHE_FILE : & str = "git-branch-management-cache.json" ;
3741const GIT_BRANCH_MANAGEMENT_CACHE_UPDATED_EVENT : & str = "git-branch-management-cache-updated" ;
3842const PR_KPI_CACHE_FILE : & str = "pr-kpi-cache.json" ;
3943const PR_KPI_CACHE_UPDATED_EVENT : & str = "pr-kpi-cache-updated" ;
4044static TOKEN_USAGE_CACHE_REFRESHING : AtomicBool = AtomicBool :: new ( false ) ;
45+ static OPENROUTER_PRICING_CACHE_REFRESHING : AtomicBool = AtomicBool :: new ( false ) ;
4146static GIT_USAGE_CACHE_REFRESHING : AtomicBool = AtomicBool :: new ( false ) ;
4247static GIT_BRANCH_MANAGEMENT_CACHE_REFRESHING : AtomicBool = AtomicBool :: new ( false ) ;
4348static 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]
210242pub 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+
12111271fn 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+
12591358async 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 ) ]
13701497struct 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+
17161886fn 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+
17401914fn 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