Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ impl AppState {
) -> BitFunResult<Self> {
let start_time = std::time::Instant::now();

// Make the usage store reachable from tools that call the model outside
// the round executor (e.g. `analyze_image`), so their consumption is
// included in the usage statistics.
token_usage::set_global_token_usage_service(token_usage_service.clone());

let config_service = config::get_global_config_service().await.map_err(|e| {
BitFunError::config(format!("Failed to get global config service: {}", e))
})?;
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub mod storage_commands;
pub mod subagent_api;
pub mod system_api;
pub mod terminal_api;
pub mod token_usage_api;
pub mod tool_api;
pub mod workspace_activation;
pub mod worktree_api;
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::LegacyUnaudited,
),
("generate_insights", RemoteWorkspacePolicy::RemoteRouted),
(
"get_token_usage_statistics",
RemoteWorkspacePolicy::RemoteRouted,
),
(
"generate_session_title",
RemoteWorkspacePolicy::LegacyUnaudited,
Expand Down
82 changes: 82 additions & 0 deletions src/apps/desktop/src/api/token_usage_api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! Token usage statistics API (settings "usage statistics" page).

use crate::api::app_state::AppState;
use bitfun_core::service::token_usage::{
types::{TimeRange, TokenUsageQuery},
UsageGranularity, UsageStatistics,
};
use chrono::{DateTime, Duration, Utc};
use log::error;
use serde::Deserialize;
use tauri::State;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsageStatisticsRequest {
/// One of "last24Hours" | "today" | "thisWeek" | "thisMonth" | "all" | "custom".
pub time_range: String,
/// One of "hour" | "day".
pub granularity: String,
#[serde(default)]
pub start: Option<DateTime<Utc>>,
#[serde(default)]
pub end: Option<DateTime<Utc>>,
#[serde(default)]
pub include_subagent: bool,
}

fn resolve_time_range(request: &TokenUsageStatisticsRequest) -> Result<TimeRange, String> {
match request.time_range.as_str() {
"today" => Ok(TimeRange::Today),
"thisWeek" => Ok(TimeRange::ThisWeek),
"thisMonth" => Ok(TimeRange::ThisMonth),
"all" => Ok(TimeRange::All),
"custom" => {
let start = request
.start
.ok_or_else(|| "custom time range requires a start timestamp".to_string())?;
let end = request.end.unwrap_or_else(Utc::now);
if end <= start {
return Err("custom time range end must be after start".to_string());
}
Ok(TimeRange::Custom { start, end })
}
_ => {
// Default and "last24Hours": the trailing 24 hours.
let end = Utc::now();
Ok(TimeRange::Custom {
start: end - Duration::hours(24),
end,
})
}
}
}

#[tauri::command]
pub async fn get_token_usage_statistics(
request: TokenUsageStatisticsRequest,
state: State<'_, AppState>,
) -> Result<UsageStatistics, String> {
let time_range = resolve_time_range(&request)?;
let granularity = match request.granularity.as_str() {
"day" => UsageGranularity::Day,
_ => UsageGranularity::Hour,
};
let query = TokenUsageQuery {
model_id: None,
session_id: None,
time_range,
limit: None,
offset: None,
include_subagent: request.include_subagent,
};

state
.token_usage_service
.get_statistics(query, granularity)
.await
.map_err(|e| {
error!("Failed to load token usage statistics: {}", e);
format!("Failed to load token usage statistics: {}", e)
})
}
2 changes: 2 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1830,6 +1830,8 @@ pub async fn run() {
api::insights_api::load_insights_report,
api::insights_api::has_insights_data,
api::insights_api::cancel_insights_generation,
// Token usage statistics API
api::token_usage_api::get_token_usage_statistics,
// SSH Remote API
api::ssh_api::ssh_list_saved_connections,
api::ssh_api::ssh_save_connection,
Expand Down
21 changes: 21 additions & 0 deletions src/crates/adapters/ai-adapters/src/models_dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,27 @@ impl ModelsDevCatalog {
.collect()
}

/// All provider ids present in the catalog.
pub fn provider_ids(&self) -> Vec<String> {
self.providers.keys().cloned().collect()
}

/// Every text-generation model in the catalog paired with the provider id
/// that owns it. Used by catalog-wide scans such as pricing lookup and
/// provider inference for historical usage records.
pub fn all_models(&self) -> Vec<(String, ModelsDevModelFacts)> {
self.providers
.iter()
.flat_map(|(provider_id, provider)| {
provider
.models
.values()
.filter(|model| model.supports_text_generation())
.map(move |model| (provider_id.clone(), model.facts()))
})
.collect()
}

pub fn canonical_model_id(&self, provider_id: &str, model_id: &str) -> Option<String> {
self.model(provider_id, model_id)
.map(|model| model.id.clone())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,59 @@ impl AnalyzeImageTool {
}
}
}

/// Persist the vision model call's token usage.
///
/// The image understanding call goes straight to `AIClient` instead of the
/// round executor, so it never produces a `TokenUsageUpdated` event. Record
/// it directly against the global usage store when one is installed.
async fn record_token_usage(
vision_model: &crate::service::config::types::AIModelConfig,
context: &ToolUseContext,
response: &crate::util::types::ai::GeminiResponse,
) {
let Some(usage) = response.usage.as_ref() else {
return;
};
let Some(service) = crate::service::token_usage::get_global_token_usage_service() else {
return;
};
if let Err(err) = service
.record_usage(
vision_model.id.clone(),
vision_model.model_name.clone(),
context.session_id.clone().unwrap_or_default(),
context.dialog_turn_id.clone().unwrap_or_default(),
usage.prompt_token_count,
usage.candidates_token_count,
usage.cached_content_token_count,
token_details_from_usage(usage),
false,
)
.await
{
log::warn!("Failed to record image analysis token usage: {}", err);
}
}
}

/// Extract provider token details (reasoning / cache read / cache write) into
/// the same `token_details` shape the round executor persists.
fn token_details_from_usage(usage: &crate::util::types::ai::GeminiUsage) -> Option<Value> {
let mut details = serde_json::Map::new();
if let Some(reasoning_tokens) = usage.reasoning_token_count {
details.insert("reasoningTokenCount".to_string(), json!(reasoning_tokens));
}
if let Some(cached_tokens) = usage.cached_content_token_count {
details.insert("cachedContentTokenCount".to_string(), json!(cached_tokens));
}
if let Some(creation_tokens) = usage.cache_creation_token_count {
details.insert(
"cacheCreationTokenCount".to_string(),
json!(creation_tokens),
);
}
(!details.is_empty()).then_some(Value::Object(details))
}

#[async_trait]
Expand Down Expand Up @@ -359,6 +412,10 @@ impl Tool for AnalyzeImageTool {
.await
.map_err(|err| BitFunError::service(format!("Image analysis failed: {err}")))?;
let analysis = response.text.trim().to_string();
// `analyze_image` calls the vision model directly through AIClient,
// bypassing the round executor's token usage event, so persist the
// usage here to keep the statistics page complete.
Self::record_token_usage(&vision_model, context, &response).await;
let summary = analysis
.lines()
.find(|line| !line.trim().is_empty())
Expand Down
11 changes: 8 additions & 3 deletions src/crates/assembly/core/src/service/token_usage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
//! Tracks and persists token consumption statistics per model, session, and turn.

mod service;
mod statistics;
mod subscriber;

pub use bitfun_services_core::token_usage::types;
pub use bitfun_services_core::token_usage::{
ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord,
TokenUsageSummary,
aggregate_statistics, ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery,
TokenUsageRecord, TokenUsageSummary, UsageAttribution, UsageGranularity, UsageStatistics,
UsageStatisticsEntry, UsageTrendPoint,
};
pub use service::TokenUsageService;
pub use service::{
get_global_token_usage_service, set_global_token_usage_service, TokenUsageService,
};
pub use statistics::UsageAttributionResolver;
pub use subscriber::TokenUsageSubscriber;
56 changes: 56 additions & 0 deletions src/crates/assembly/core/src/service/token_usage/service.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
//! Compatibility wrapper for token usage persistence.

use super::statistics::UsageAttributionResolver;
use super::types::{
ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord,
TokenUsageSummary,
};
use crate::infrastructure::PathManager;
use crate::service::config::types::AIModelConfig;
use anyhow::Result;
use bitfun_services_core::token_usage::{aggregate_statistics, UsageGranularity, UsageStatistics};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
Expand Down Expand Up @@ -110,6 +113,40 @@ impl TokenUsageService {
.map_err(anyhow::Error::msg)
}

/// Aggregate persisted records into dashboard statistics.
///
/// Attribution (provider group, endpoint, estimated price) prefers the
/// current model configuration for each record's `model_config_id` and
/// falls back to the bundled models.dev catalog inferred from the
/// effective model name, so records survive config deletion.
pub async fn get_statistics(
&self,
query: TokenUsageQuery,
granularity: UsageGranularity,
) -> Result<UsageStatistics> {
let records = self
.inner
.query_records(query)
.await
.map_err(anyhow::Error::msg)?;

let models_dev = crate::infrastructure::ai::reasoning_catalog::
load_models_dev_reasoning_catalog_without_refresh()
.await
.catalog;
let configs = crate::service::config::get_global_config_service()
.await
.map_err(anyhow::Error::msg)?
.get_config::<Vec<AIModelConfig>>(Some("ai.models"))
.await
.unwrap_or_default();
let resolver = UsageAttributionResolver::new(models_dev.as_deref(), &configs);

Ok(aggregate_statistics(&records, granularity, |record| {
resolver.attribute(record)
}))
}

pub async fn clear_model_stats(&self, model_id: &str) -> Result<()> {
self.inner
.clear_model_stats(model_id)
Expand All @@ -124,3 +161,22 @@ impl TokenUsageService {
.map_err(anyhow::Error::msg)
}
}

static GLOBAL_TOKEN_USAGE_SERVICE: std::sync::OnceLock<Arc<TokenUsageService>> =
std::sync::OnceLock::new();

/// Install the process-wide token usage service. Called once by the desktop
/// runtime after the service is constructed; tools that call the model outside
/// the round executor (e.g. `analyze_image`) use it to persist usage that would
/// otherwise never reach the token usage store.
pub fn set_global_token_usage_service(service: Arc<TokenUsageService>) {
match GLOBAL_TOKEN_USAGE_SERVICE.set(service) {
Ok(_) => log::info!("Global token usage service set"),
Err(_) => log::info!("Global token usage service already exists, skipping set"),
}
}

/// Access the process-wide token usage service, if installed.
pub fn get_global_token_usage_service() -> Option<Arc<TokenUsageService>> {
GLOBAL_TOKEN_USAGE_SERVICE.get().cloned()
}
Loading