From efb50737bf53ac784666389b047f7a376b38d7a4 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 26 Feb 2026 18:25:39 -0500 Subject: [PATCH] feat: implement provider credit tracking and balance management - Added 'credit_balance' and 'low_credit_threshold' to 'provider_configs' table. - Updated dashboard backend to support reading and updating provider credits. - Implemented real-time credit deduction from provider balances on successful requests. - Added visual balance indicators and configuration modal to the 'Providers' dashboard tab. --- src/dashboard/mod.rs | 25 ++++++++++++++++++++----- src/database/mod.rs | 2 ++ src/logging/mod.rs | 19 +++++++++++++++++-- static/js/pages/providers.js | 28 +++++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 8f8cd178..d20ca098 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -536,7 +536,7 @@ async fn handle_get_providers(State(state): State) -> Json) -> Json = row.get("base_url"); - db_configs.insert(id, (enabled, base_url)); + let balance: f64 = row.get("credit_balance"); + let threshold: f64 = row.get("low_credit_threshold"); + db_configs.insert(id, (enabled, base_url, balance, threshold)); } } @@ -566,12 +568,17 @@ async fn handle_get_providers(State(state): State) -> Json (false, "".to_string(), "Unknown"), }; + let mut balance = 0.0; + let mut threshold = 5.0; + // Apply database overrides - if let Some((db_enabled, db_url)) = db_configs.get(id) { + if let Some((db_enabled, db_url, db_balance, db_threshold)) = db_configs.get(id) { enabled = *db_enabled; if let Some(url) = db_url { base_url = url.clone(); } + balance = *db_balance; + threshold = *db_threshold; } // Find models for this provider in registry @@ -606,6 +613,8 @@ async fn handle_get_providers(State(state): State) -> Json, })); } @@ -625,6 +634,8 @@ struct UpdateProviderRequest { enabled: bool, base_url: Option, api_key: Option, + credit_balance: Option, + low_credit_threshold: Option, } async fn handle_update_provider( @@ -637,12 +648,14 @@ async fn handle_update_provider( // Update or insert into database let result = sqlx::query( r#" - INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key) - VALUES (?, ?, ?, ?, ?) + INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, base_url = excluded.base_url, api_key = COALESCE(excluded.api_key, provider_configs.api_key), + credit_balance = COALESCE(excluded.credit_balance, provider_configs.credit_balance), + low_credit_threshold = COALESCE(excluded.low_credit_threshold, provider_configs.low_credit_threshold), updated_at = CURRENT_TIMESTAMP "# ) @@ -651,6 +664,8 @@ async fn handle_update_provider( .bind(payload.enabled) .bind(&payload.base_url) .bind(&payload.api_key) + .bind(payload.credit_balance) + .bind(payload.low_credit_threshold) .execute(pool) .await; diff --git a/src/database/mod.rs b/src/database/mod.rs index 33940244..55e526b8 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -87,6 +87,8 @@ async fn run_migrations(pool: &DbPool) -> Result<()> { enabled BOOLEAN DEFAULT TRUE, base_url TEXT, api_key TEXT, + credit_balance REAL DEFAULT 0.0, + low_credit_threshold REAL DEFAULT 5.0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) "# diff --git a/src/logging/mod.rs b/src/logging/mod.rs index 1eff5656..aa46a9fe 100644 --- a/src/logging/mod.rs +++ b/src/logging/mod.rs @@ -56,6 +56,8 @@ impl RequestLogger { /// Insert a log entry into the database async fn insert_log(pool: &SqlitePool, log: RequestLog) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query( r#" INSERT INTO llm_requests @@ -65,7 +67,7 @@ impl RequestLogger { ) .bind(log.timestamp) .bind(log.client_id) - .bind(log.provider) + .bind(&log.provider) .bind(log.model) .bind(log.prompt_tokens as i64) .bind(log.completion_tokens as i64) @@ -77,9 +79,22 @@ impl RequestLogger { .bind(log.duration_ms as i64) .bind(None::) // request_body - TODO: store serialized request .bind(None::) // response_body - TODO: store serialized response or error - .execute(pool) + .execute(&mut *tx) .await?; + // Deduct from provider balance if successful + if log.cost > 0.0 { + sqlx::query( + "UPDATE provider_configs SET credit_balance = credit_balance - ? WHERE id = ?" + ) + .bind(log.cost) + .bind(&log.provider) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) } } diff --git a/static/js/pages/providers.js b/static/js/pages/providers.js index b4d311fc..0b9eeb5a 100644 --- a/static/js/pages/providers.js +++ b/static/js/pages/providers.js @@ -43,6 +43,10 @@ class ProvidersPage { const statusClass = provider.status === 'online' ? 'success' : 'warning'; const modelCount = provider.models ? provider.models.length : 0; + // Credit balance display logic + const isLowBalance = provider.credit_balance <= provider.low_credit_threshold && provider.id !== 'ollama'; + const balanceColor = isLowBalance ? 'var(--red-light)' : 'var(--green-light)'; + return `
@@ -61,6 +65,11 @@ class ProvidersPage { ${modelCount} Models Available
+
+ + Balance: ${provider.id === 'ollama' ? 'FREE' : window.api.formatCurrency(provider.credit_balance)} + ${isLowBalance ? '' : ''} +
Last used: ${provider.last_used ? window.api.formatTimeAgo(provider.last_used) : 'Never'} @@ -146,12 +155,21 @@ class ProvidersPage {
- +
- Leave blank to keep existing key from .env or config.toml +
+
+
+ + +
+
+ + +