Skip to content
Open
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
642 changes: 642 additions & 0 deletions src-tauri/src/analytics.rs

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src-tauri/src/commands/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ pub async fn claim_codex_reset_credit(
.await
.map_err(|_| "The reset claim could not be completed.".to_owned())?;

if outcome == ResetClaimOutcome::Success {
// Do not turn a successful provider reset into a retryable claim on disk failure.
if crate::analytics::record_banked_reset(&app, "codex", chrono::Utc::now()).is_err() {
let _ = app.emit(
"analytics-reset-warning",
"Reset succeeded, but the local analytics marker was not saved.",
);
}
}
if outcome != ResetClaimOutcome::Failed {
let observed_account_revision = AtomicU64::new(settings.account_revision());
service.refresh("codex", true).await;
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod analytics;
mod child_process;
mod commands;
mod desktop_integration;
Expand Down Expand Up @@ -361,11 +362,13 @@ pub fn run() {
let app_data_dir = app.path().app_data_dir()?;
let database_path = app_data_dir.join("openquota.db");
let storage = Arc::new(Storage::open(&database_path)?);
app.manage(storage.clone());
provider_environment::initialize(storage.load_provider_environment()?);
provider_environment::refresh_for_next_launch(storage.clone());
app.manage(Arc::new(PanelResizeSession::new(storage.clone())));
app_debug!("cache", "application database opened");
let pricing = Arc::new(PricingStore::new(app_data_dir.join("pricing"))?);
app.manage(pricing.clone());
let mut providers = claude::runtimes(storage.clone(), pricing.clone())?;
providers.extend(vec![
Arc::new(CodexProvider::new(storage.clone(), pricing.clone())?)
Expand Down Expand Up @@ -482,6 +485,9 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
analytics::get_range_analytics,
analytics::get_banked_reset_marker,
analytics::set_banked_reset_marker,
commands::bootstrap::get_bootstrap_state,
commands::provider::open_provider_link,
commands::provider::get_provider_api_key_state,
Expand Down
7 changes: 5 additions & 2 deletions src-tauri/src/providers/claude/local_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ pub fn scan_local_usage(
Ok(accumulator.build(now, source_note))
}

fn discover_files(configured_roots: &[PathBuf], include_standard_roots: bool) -> Vec<PathBuf> {
pub(crate) fn discover_files(
configured_roots: &[PathBuf],
include_standard_roots: bool,
) -> Vec<PathBuf> {
let home = home_directory();
let mut roots = if include_standard_roots {
let config = env_text("CLAUDE_CONFIG_DIR");
Expand Down Expand Up @@ -385,7 +388,7 @@ fn usage_event(
})
}

fn deduplicate(events: Vec<ClaudeTokenEvent>) -> Vec<ClaudeTokenEvent> {
pub(crate) fn deduplicate(events: Vec<ClaudeTokenEvent>) -> Vec<ClaudeTokenEvent> {
let mut output = Vec::new();
let mut exact = HashMap::<(String, Option<String>), usize>::new();
let mut by_message = HashMap::<String, Vec<usize>>::new();
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/providers/claude/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
mod accounts;
pub mod auth;
mod client;
mod local_usage;
pub(crate) mod local_usage;
mod mapper;

use std::{
Expand Down
10 changes: 5 additions & 5 deletions src-tauri/src/providers/codex/local_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ fn scan_codex_events(
Ok(events)
}

fn codex_homes(configured_home: Option<&OsStr>, home: &Path) -> Vec<PathBuf> {
pub(crate) fn codex_homes(configured_home: Option<&OsStr>, home: &Path) -> Vec<PathBuf> {
if let Some(configured_home) = configured_home.filter(|value| !value.is_empty()) {
let configured_home = configured_home.to_string_lossy();
if !configured_home.trim().is_empty() {
Expand Down Expand Up @@ -135,14 +135,14 @@ fn expand_home(value: &str, home: &Path) -> PathBuf {
PathBuf::from(value)
}

fn home_directory() -> PathBuf {
pub(crate) fn home_directory() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_default()
}

fn discover_session_files(homes: &[PathBuf]) -> Vec<PathBuf> {
pub(crate) fn discover_session_files(homes: &[PathBuf]) -> Vec<PathBuf> {
let mut output = Vec::new();
let mut seen_directories = HashSet::new();
for home in homes {
Expand Down Expand Up @@ -350,7 +350,7 @@ impl ChildReplayGate {
}
}

fn is_child_session_meta(payload: &Value) -> bool {
pub(crate) fn is_child_session_meta(payload: &Value) -> bool {
has_non_null_value(payload.get("forked_from_id"))
|| has_non_null_value(payload.get("parent_thread_id"))
|| payload.get("thread_source").and_then(Value::as_str) == Some("subagent")
Expand Down Expand Up @@ -520,7 +520,7 @@ fn aggregate_into(
}
}

fn estimate_cost(event: &TokenEvent, pricing: &ModelPricing) -> Option<f64> {
pub(crate) fn estimate_cost(event: &TokenEvent, pricing: &ModelPricing) -> Option<f64> {
let display_model = event.model.trim();
let model = if display_model == "codex-auto-review" {
auto_review_fallback(&event.timestamp)
Expand Down
23 changes: 23 additions & 0 deletions src-tauri/src/providers/daily_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,29 @@ impl DailyUsageAccumulator {
}
}

/// Builds only the model detail from every event previously admitted by the caller. Range
/// analytics keeps its independently measured total, including events without known pricing.
pub fn build_range_details(
self,
source_note: &str,
) -> (Option<ModelUsageBreakdown>, Vec<String>) {
let mut total = DayAccumulator::default();
for day in self.days.values() {
for (key, model) in &day.models {
total.models.entry(key.clone()).or_default().merge(model);
}
}
let mut unknown_models = self
.unknown_models_by_day
.values()
.flat_map(|models| models.iter().cloned())
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
unknown_models.sort();
(model_breakdown(&total, source_note), unknown_models)
}

fn period_for_days(&self, dates: &[NaiveDate], source_note: &str) -> Option<UsagePeriod> {
let mut total = DayAccumulator::default();
let mut unknown_models = HashSet::new();
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ pub mod copilot;
pub mod credential_store;
pub mod cursor;
mod daily_usage;
pub(crate) use daily_usage::DailyUsageAccumulator;
mod detection;
pub mod devin;
pub mod grok;
pub mod kimi;
mod log_usage;
pub(crate) mod log_usage;
pub mod minimax;
pub mod opencode;
pub mod openrouter;
Expand Down
149 changes: 149 additions & 0 deletions src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ vi.mock('@tauri-apps/api/window', () => ({
type InvokeArgs = {
settings?: SettingsViewState['settings'];
providerId?: string;
provider?: string;
start?: string;
end?: string;
linkIndex?: number;
height?: number;
};
Expand Down Expand Up @@ -100,6 +103,33 @@ describe('OpenQuota dashboard', () => {
if (command === 'lock_panel_resize_axis') return Promise.resolve();
if (command === 'get_log_path') return Promise.resolve('C:\\OpenQuota\\logs\\OpenQuota.log');
if (command === 'open_log_folder') return Promise.resolve();
if (command === 'get_banked_reset_marker') return Promise.resolve(null);
if (command === 'get_range_analytics')
return Promise.resolve({
tokens: args?.provider === 'claude' ? 500 : 700,
estimatedCostUsd: args?.provider === 'claude' ? 5 : 7,
unpricedEvents: 0,
completedTasks: 1,
pricedTasks: 1,
averageCostUsd: args?.provider === 'claude' ? 5 : 7,
timedTasks: 1,
averageSeconds: 60,
filesRead: 1,
unreadableFiles: 0,
malformedLines: 0,
earliestEvent: args?.start ?? null,
modelBreakdown: {
sourceNote: 'From local logs',
models: [
{
model: args?.provider === 'claude' ? 'claude-opus' : 'gpt-5',
totalTokens: args?.provider === 'claude' ? 500 : 700,
costUsd: args?.provider === 'claude' ? 5 : 7,
},
],
},
unknownModels: [],
});
if (command === 'dismiss_main_window') return Promise.resolve();
if (command === 'check_for_updates')
return Promise.resolve({
Expand Down Expand Up @@ -505,6 +535,125 @@ describe('OpenQuota dashboard', () => {
);
});

it('opens the custom range from the top period switcher with all providers selected', async () => {
render(App);
await screen.findByText('Plus');
await fireEvent.click(screen.getByRole('button', { name: 'Custom' }));
const dialog = screen.getByRole('dialog', { name: 'Custom range' });
expect(within(dialog).getByLabelText('Provider')).toHaveValue('all');
expect(within(dialog).getByText('All providers are selected by default.')).toBeVisible();
expect(within(dialog).getByText('Local times')).toBeVisible();
await fireEvent.click(within(dialog).getByRole('button', { name: 'Calculate' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('active');
expect(screen.getByRole('button', { name: 'Today' })).not.toHaveClass('active');
const spend = screen.getByRole('region', { name: 'Total Spend' });
expect(spend.querySelector('.spend-legend')).toHaveTextContent('Codex');

const codex = screen.getByRole('group', { name: 'Codex provider' });
await fireEvent.click(within(codex).getByRole('button', { name: 'Show more' }));
expect(within(codex).getByText('Custom')).toBeVisible();
});

it('loads since-last-reset usage from the live provider boundary before reporting data', async () => {
const now = Date.now();
const nextReset = new Date(now + 4 * 24 * 60 * 60 * 1000).toISOString();
const rangeState: UsageViewState = {
providers: {
codex: {
...codexState,
snapshot: {
...codexState.snapshot!,
quotas: codexState.snapshot!.quotas.map((quota) =>
quota.id === 'weekly' ? { ...quota, resetsAt: nextReset } : quota,
),
},
},
},
};
const rangeSettings: SettingsViewState = {
...settingsState,
settings: {
...settingsState.settings,
providers: settingsState.settings.providers.map((provider) => ({
...provider,
expanded: true,
})),
},
};
let resolveRange!: (value: {
tokens: number;
estimatedCostUsd: number;
unpricedEvents: number;
completedTasks: number;
pricedTasks: number;
averageCostUsd: number;
timedTasks: number;
averageSeconds: number;
filesRead: number;
unreadableFiles: number;
malformedLines: number;
earliestEvent: string;
modelBreakdown: {
sourceNote: string;
models: Array<{ model: string; totalTokens: number; costUsd: number }>;
};
unknownModels: string[];
}) => void;
const pendingRange = new Promise<Parameters<typeof resolveRange>[0]>((resolve) => {
resolveRange = resolve;
});
const defaultInvoke = mocks.invoke.getMockImplementation()!;
mockInvoke((command: string, args?: InvokeArgs) => {
if (command === 'get_usage_state') return Promise.resolve(rangeState);
if (command === 'get_app_settings') return Promise.resolve(rangeSettings);
if (command === 'get_range_analytics') return pendingRange;
return defaultInvoke(command, args);
});

render(App);
const codex = await screen.findByRole('group', { name: 'Codex provider' });
expect(within(codex).getByText('Since last reset')).toBeVisible();
expect(within(codex).getByRole('button', { name: 'Loading…' })).toHaveAttribute(
'aria-busy',
'true',
);
expect(within(codex).queryByRole('button', { name: 'No data' })).not.toBeInTheDocument();

await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith('get_range_analytics', {
start: new Date(Date.parse(nextReset) - 604_800_000).toISOString(),
end: expect.any(String),
provider: 'codex',
}),
);
resolveRange({
tokens: 4_200_000,
estimatedCostUsd: 12.5,
unpricedEvents: 0,
completedTasks: 2,
pricedTasks: 2,
averageCostUsd: 6.25,
timedTasks: 2,
averageSeconds: 90,
filesRead: 3,
unreadableFiles: 0,
malformedLines: 0,
earliestEvent: new Date(Date.parse(nextReset) - 604_800_000).toISOString(),
modelBreakdown: {
sourceNote: 'From your Codex logs (estimated)',
models: [{ model: 'gpt-5', totalTokens: 4_200_000, costUsd: 12.5 }],
},
unknownModels: [],
});

const resetUsage = await within(codex).findByRole('button', {
name: '$12.50 · 4.2M tokens',
});
expect(resetUsage).toHaveAttribute('aria-busy', 'false');
expect(resetUsage).toHaveClass('usage-reading-interactive');
});

it('explains unavailable cost and reveals measured tokens for the same period', async () => {
mockInvoke((command: string, args?: { settings?: SettingsViewState['settings'] }) => {
if (command === 'get_usage_state')
Expand Down
Loading