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.
This commit is contained in:
@@ -536,7 +536,7 @@ async fn handle_get_providers(State(state): State<DashboardState>) -> Json<ApiRe
|
|||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Load all overrides from database
|
// Load all overrides from database
|
||||||
let db_configs_result = sqlx::query("SELECT id, enabled, base_url FROM provider_configs")
|
let db_configs_result = sqlx::query("SELECT id, enabled, base_url, credit_balance, low_credit_threshold FROM provider_configs")
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -546,7 +546,9 @@ async fn handle_get_providers(State(state): State<DashboardState>) -> Json<ApiRe
|
|||||||
let id: String = row.get("id");
|
let id: String = row.get("id");
|
||||||
let enabled: bool = row.get("enabled");
|
let enabled: bool = row.get("enabled");
|
||||||
let base_url: Option<String> = row.get("base_url");
|
let base_url: Option<String> = 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<DashboardState>) -> Json<ApiRe
|
|||||||
_ => (false, "".to_string(), "Unknown"),
|
_ => (false, "".to_string(), "Unknown"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut balance = 0.0;
|
||||||
|
let mut threshold = 5.0;
|
||||||
|
|
||||||
// Apply database overrides
|
// 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;
|
enabled = *db_enabled;
|
||||||
if let Some(url) = db_url {
|
if let Some(url) = db_url {
|
||||||
base_url = url.clone();
|
base_url = url.clone();
|
||||||
}
|
}
|
||||||
|
balance = *db_balance;
|
||||||
|
threshold = *db_threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find models for this provider in registry
|
// Find models for this provider in registry
|
||||||
@@ -606,6 +613,8 @@ async fn handle_get_providers(State(state): State<DashboardState>) -> Json<ApiRe
|
|||||||
"status": status,
|
"status": status,
|
||||||
"models": models,
|
"models": models,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
|
"credit_balance": balance,
|
||||||
|
"low_credit_threshold": threshold,
|
||||||
"last_used": None::<String>,
|
"last_used": None::<String>,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -625,6 +634,8 @@ struct UpdateProviderRequest {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
base_url: Option<String>,
|
base_url: Option<String>,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
|
credit_balance: Option<f64>,
|
||||||
|
low_credit_threshold: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_update_provider(
|
async fn handle_update_provider(
|
||||||
@@ -637,12 +648,14 @@ async fn handle_update_provider(
|
|||||||
// Update or insert into database
|
// Update or insert into database
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key)
|
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
base_url = excluded.base_url,
|
base_url = excluded.base_url,
|
||||||
api_key = COALESCE(excluded.api_key, provider_configs.api_key),
|
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
|
updated_at = CURRENT_TIMESTAMP
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
@@ -651,6 +664,8 @@ async fn handle_update_provider(
|
|||||||
.bind(payload.enabled)
|
.bind(payload.enabled)
|
||||||
.bind(&payload.base_url)
|
.bind(&payload.base_url)
|
||||||
.bind(&payload.api_key)
|
.bind(&payload.api_key)
|
||||||
|
.bind(payload.credit_balance)
|
||||||
|
.bind(payload.low_credit_threshold)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
|
|||||||
enabled BOOLEAN DEFAULT TRUE,
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
base_url TEXT,
|
base_url TEXT,
|
||||||
api_key TEXT,
|
api_key TEXT,
|
||||||
|
credit_balance REAL DEFAULT 0.0,
|
||||||
|
low_credit_threshold REAL DEFAULT 5.0,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
"#
|
"#
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ impl RequestLogger {
|
|||||||
|
|
||||||
/// Insert a log entry into the database
|
/// Insert a log entry into the database
|
||||||
async fn insert_log(pool: &SqlitePool, log: RequestLog) -> Result<(), sqlx::Error> {
|
async fn insert_log(pool: &SqlitePool, log: RequestLog) -> Result<(), sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO llm_requests
|
INSERT INTO llm_requests
|
||||||
@@ -65,7 +67,7 @@ impl RequestLogger {
|
|||||||
)
|
)
|
||||||
.bind(log.timestamp)
|
.bind(log.timestamp)
|
||||||
.bind(log.client_id)
|
.bind(log.client_id)
|
||||||
.bind(log.provider)
|
.bind(&log.provider)
|
||||||
.bind(log.model)
|
.bind(log.model)
|
||||||
.bind(log.prompt_tokens as i64)
|
.bind(log.prompt_tokens as i64)
|
||||||
.bind(log.completion_tokens as i64)
|
.bind(log.completion_tokens as i64)
|
||||||
@@ -77,9 +79,22 @@ impl RequestLogger {
|
|||||||
.bind(log.duration_ms as i64)
|
.bind(log.duration_ms as i64)
|
||||||
.bind(None::<String>) // request_body - TODO: store serialized request
|
.bind(None::<String>) // request_body - TODO: store serialized request
|
||||||
.bind(None::<String>) // response_body - TODO: store serialized response or error
|
.bind(None::<String>) // response_body - TODO: store serialized response or error
|
||||||
.execute(pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ class ProvidersPage {
|
|||||||
const statusClass = provider.status === 'online' ? 'success' : 'warning';
|
const statusClass = provider.status === 'online' ? 'success' : 'warning';
|
||||||
const modelCount = provider.models ? provider.models.length : 0;
|
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 `
|
return `
|
||||||
<div class="provider-card ${provider.status}">
|
<div class="provider-card ${provider.status}">
|
||||||
<div class="provider-card-header">
|
<div class="provider-card-header">
|
||||||
@@ -61,6 +65,11 @@ class ProvidersPage {
|
|||||||
<i class="fas fa-microchip"></i>
|
<i class="fas fa-microchip"></i>
|
||||||
<span>${modelCount} Models Available</span>
|
<span>${modelCount} Models Available</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-item" style="color: ${balanceColor}; font-weight: 700;">
|
||||||
|
<i class="fas fa-wallet"></i>
|
||||||
|
<span>Balance: ${provider.id === 'ollama' ? 'FREE' : window.api.formatCurrency(provider.credit_balance)}</span>
|
||||||
|
${isLowBalance ? '<i class="fas fa-exclamation-triangle" title="Low Balance"></i>' : ''}
|
||||||
|
</div>
|
||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<i class="fas fa-clock"></i>
|
<i class="fas fa-clock"></i>
|
||||||
<span>Last used: ${provider.last_used ? window.api.formatTimeAgo(provider.last_used) : 'Never'}</span>
|
<span>Last used: ${provider.last_used ? window.api.formatTimeAgo(provider.last_used) : 'Never'}</span>
|
||||||
@@ -146,12 +155,21 @@ class ProvidersPage {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label for="provider-base-url">Base URL</label>
|
<label for="provider-base-url">Base URL</label>
|
||||||
<input type="text" id="provider-base-url" value="${provider.base_url || ''}" placeholder="Default: ${provider.id === 'ollama' ? 'http://localhost:11434/v1' : 'Standard API URL'}">
|
<input type="text" id="provider-base-url" value="${provider.base_url || ''}" placeholder="Default API URL">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label for="provider-api-key">API Key (Optional / Overwrite)</label>
|
<label for="provider-api-key">API Key (Optional / Overwrite)</label>
|
||||||
<input type="password" id="provider-api-key" placeholder="••••••••••••••••">
|
<input type="password" id="provider-api-key" placeholder="••••••••••••••••">
|
||||||
<small>Leave blank to keep existing key from .env or config.toml</small>
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="provider-balance">Current Credit Balance ($)</label>
|
||||||
|
<input type="number" id="provider-balance" value="${provider.credit_balance}" step="0.01">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="provider-threshold">Low Balance Alert ($)</label>
|
||||||
|
<input type="number" id="provider-threshold" value="${provider.low_credit_threshold}" step="0.50">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -167,12 +185,16 @@ class ProvidersPage {
|
|||||||
const enabled = modal.querySelector('#provider-enabled').checked;
|
const enabled = modal.querySelector('#provider-enabled').checked;
|
||||||
const baseUrl = modal.querySelector('#provider-base-url').value;
|
const baseUrl = modal.querySelector('#provider-base-url').value;
|
||||||
const apiKey = modal.querySelector('#provider-api-key').value;
|
const apiKey = modal.querySelector('#provider-api-key').value;
|
||||||
|
const balance = parseFloat(modal.querySelector('#provider-balance').value);
|
||||||
|
const threshold = parseFloat(modal.querySelector('#provider-threshold').value);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.api.put(`/providers/${id}`, {
|
await window.api.put(`/providers/${id}`, {
|
||||||
enabled,
|
enabled,
|
||||||
base_url: baseUrl || null,
|
base_url: baseUrl || null,
|
||||||
api_key: apiKey || null
|
api_key: apiKey || null,
|
||||||
|
credit_balance: isNaN(balance) ? null : balance,
|
||||||
|
low_credit_threshold: isNaN(threshold) ? null : threshold
|
||||||
});
|
});
|
||||||
|
|
||||||
window.authManager.showToast(`${provider.name} configuration saved`, 'success');
|
window.authManager.showToast(`${provider.name} configuration saved`, 'success');
|
||||||
|
|||||||
Reference in New Issue
Block a user