forked from boos/BoosAPI
feat: refine BoosAPI console and user workspace
This commit is contained in:
@@ -4,6 +4,7 @@ use crate::{
|
||||
};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -22,6 +23,23 @@ pub struct UsageSummary {
|
||||
pub total_cost: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComfyTaskBillingEvent {
|
||||
pub key_id: Option<i64>,
|
||||
pub model: String,
|
||||
pub task_type: &'static str,
|
||||
pub client_family: &'static str,
|
||||
pub client_app_id: &'static str,
|
||||
pub client_app_name: &'static str,
|
||||
pub prompt_tokens: i64,
|
||||
pub completion_tokens: i64,
|
||||
pub cost: f64,
|
||||
pub content_summary: Option<String>,
|
||||
pub request_content: Option<String>,
|
||||
pub response_content: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl UsageTracker {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self { db }
|
||||
@@ -119,6 +137,112 @@ impl UsageTracker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn record_comfy_task_success(
|
||||
&self,
|
||||
event: ComfyTaskBillingEvent,
|
||||
) -> AppResult<Value> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
Self::ensure_proxy_log_task_columns(&self.db).await?;
|
||||
let prompt_tokens = event.prompt_tokens.max(0);
|
||||
let completion_tokens = event.completion_tokens.max(0);
|
||||
let total_tokens = prompt_tokens + completion_tokens;
|
||||
let cost = event.cost.max(0.0);
|
||||
let billing_details = comfy_task_billing_details(&event, "success", cost);
|
||||
|
||||
let mut tx = self.db.begin().await?;
|
||||
if let Some(key_id) = event.key_id {
|
||||
sqlx::query(
|
||||
"INSERT INTO usage_logs
|
||||
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(&event.model)
|
||||
.bind(prompt_tokens)
|
||||
.bind(completion_tokens)
|
||||
.bind(total_tokens)
|
||||
.bind(cost)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys
|
||||
SET used_requests = COALESCE(used_requests, 0) + 1,
|
||||
used_cost = COALESCE(used_cost, 0) + ?,
|
||||
last_used_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(cost)
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO proxy_logs (
|
||||
downstream_api_key_id, model_requested, model_actual, status, http_status,
|
||||
prompt_tokens, completion_tokens, total_tokens, estimated_cost,
|
||||
retry_count, is_stream, client_family, client_app_id, client_app_name,
|
||||
client_confidence, billing_details, content_summary, request_content,
|
||||
response_content
|
||||
) VALUES (?, ?, ?, 'success', 200, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'high', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(event.key_id)
|
||||
.bind(&event.model)
|
||||
.bind(&event.model)
|
||||
.bind(prompt_tokens)
|
||||
.bind(completion_tokens)
|
||||
.bind(total_tokens)
|
||||
.bind(cost)
|
||||
.bind(event.client_family)
|
||||
.bind(event.client_app_id)
|
||||
.bind(event.client_app_name)
|
||||
.bind(billing_details.to_string())
|
||||
.bind(event.content_summary.as_deref())
|
||||
.bind(event.request_content.as_deref())
|
||||
.bind(event.response_content.as_deref())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(billing_details)
|
||||
}
|
||||
|
||||
pub async fn record_comfy_task_failure(
|
||||
&self,
|
||||
event: ComfyTaskBillingEvent,
|
||||
) -> AppResult<Value> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
Self::ensure_proxy_log_task_columns(&self.db).await?;
|
||||
let billing_details = comfy_task_billing_details(&event, "failed", 0.0);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO proxy_logs (
|
||||
downstream_api_key_id, model_requested, model_actual, status, http_status,
|
||||
prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message,
|
||||
retry_count, is_stream, client_family, client_app_id, client_app_name,
|
||||
client_confidence, billing_details, content_summary, request_content,
|
||||
response_content
|
||||
) VALUES (?, ?, ?, 'failed', 502, 0, 0, 0, 0.0, ?, 0, 0, ?, ?, ?, 'high', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(event.key_id)
|
||||
.bind(&event.model)
|
||||
.bind(&event.model)
|
||||
.bind(event.error_message.as_deref().unwrap_or("Comfy task failed"))
|
||||
.bind(event.client_family)
|
||||
.bind(event.client_app_id)
|
||||
.bind(event.client_app_name)
|
||||
.bind(billing_details.to_string())
|
||||
.bind(event.content_summary.as_deref())
|
||||
.bind(event.request_content.as_deref())
|
||||
.bind(event.response_content.as_deref())
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
|
||||
Ok(billing_details)
|
||||
}
|
||||
|
||||
pub async fn create_daily_usage_snapshot(&self) -> AppResult<()> {
|
||||
Self::create_daily_usage_snapshot_for_db(&self.db).await
|
||||
}
|
||||
@@ -485,6 +609,112 @@ impl UsageTracker {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_proxy_log_task_columns(db: &SqlitePool) -> AppResult<()> {
|
||||
for (name, definition) in [
|
||||
("client_family", "TEXT"),
|
||||
("client_app_id", "TEXT"),
|
||||
("client_app_name", "TEXT"),
|
||||
("client_confidence", "TEXT"),
|
||||
("is_stream", "INTEGER"),
|
||||
("first_byte_latency_ms", "INTEGER"),
|
||||
("billing_details", "TEXT"),
|
||||
("content_summary", "TEXT"),
|
||||
("request_content", "TEXT"),
|
||||
("response_content", "TEXT"),
|
||||
] {
|
||||
ensure_table_column(db, "proxy_logs", name, definition).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_table_column(
|
||||
db: &SqlitePool,
|
||||
table: &str,
|
||||
name: &str,
|
||||
definition: &str,
|
||||
) -> AppResult<()> {
|
||||
let info_sql = format!("PRAGMA table_info({table})");
|
||||
let rows = sqlx::query(&info_sql).fetch_all(db).await?;
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let exists = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("name").ok())
|
||||
.any(|column| column == name);
|
||||
if exists {
|
||||
return Ok(());
|
||||
}
|
||||
let alter_sql = format!("ALTER TABLE {table} ADD COLUMN {name} {definition}");
|
||||
sqlx::query(&alter_sql).execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn comfy_task_billing_details(
|
||||
event: &ComfyTaskBillingEvent,
|
||||
status: &str,
|
||||
total_cost: f64,
|
||||
) -> Value {
|
||||
let prompt_tokens = event.prompt_tokens.max(0);
|
||||
let completion_tokens = event.completion_tokens.max(0);
|
||||
let total_tokens = prompt_tokens + completion_tokens;
|
||||
json!({
|
||||
"source": "comfy-native-task",
|
||||
"taskType": event.task_type,
|
||||
"status": status,
|
||||
"currency": "USD",
|
||||
"charged": status == "success" && event.key_id.is_some(),
|
||||
"totalCost": total_cost,
|
||||
"usage": {
|
||||
"promptTokens": prompt_tokens,
|
||||
"completionTokens": completion_tokens,
|
||||
"totalTokens": total_tokens,
|
||||
"cacheReadTokens": 0,
|
||||
"cacheCreationTokens": 0,
|
||||
"billablePromptTokens": prompt_tokens,
|
||||
"promptTokensIncludeCache": false,
|
||||
},
|
||||
"pricing": {
|
||||
"modelRatio": 1,
|
||||
"completionRatio": 1,
|
||||
"cacheRatio": 0,
|
||||
"cacheCreationRatio": 0,
|
||||
"groupRatio": 1,
|
||||
},
|
||||
"breakdown": {
|
||||
"inputPerMillion": 0,
|
||||
"outputPerMillion": 0,
|
||||
"cacheReadPerMillion": 0,
|
||||
"cacheCreationPerMillion": 0,
|
||||
"inputCost": 0,
|
||||
"outputCost": total_cost,
|
||||
"cacheReadCost": 0,
|
||||
"cacheCreationCost": 0,
|
||||
"totalCost": total_cost,
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"name": "submit_workflow",
|
||||
"status": status,
|
||||
"model": event.model,
|
||||
"cost": 0.0,
|
||||
},
|
||||
{
|
||||
"name": "generate_outputs",
|
||||
"status": status,
|
||||
"model": event.model,
|
||||
"cost": total_cost,
|
||||
},
|
||||
{
|
||||
"name": "archive_outputs",
|
||||
"status": status,
|
||||
"model": event.model,
|
||||
"cost": 0.0,
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async fn touch_projection_checkpoint(
|
||||
@@ -614,6 +844,56 @@ mod tests {
|
||||
pool
|
||||
}
|
||||
|
||||
async fn create_proxy_logs(pool: &SqlitePool) {
|
||||
sqlx::query(
|
||||
"CREATE TABLE proxy_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
downstream_api_key_id INTEGER,
|
||||
model_requested TEXT,
|
||||
model_actual TEXT,
|
||||
status TEXT,
|
||||
http_status INTEGER,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
estimated_cost REAL,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
is_stream INTEGER,
|
||||
client_family TEXT,
|
||||
client_app_id TEXT,
|
||||
client_app_name TEXT,
|
||||
client_confidence TEXT,
|
||||
billing_details TEXT,
|
||||
content_summary TEXT,
|
||||
request_content TEXT,
|
||||
response_content TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn test_comfy_event(cost: f64) -> ComfyTaskBillingEvent {
|
||||
ComfyTaskBillingEvent {
|
||||
key_id: Some(1),
|
||||
model: "seedance-comfy".to_string(),
|
||||
task_type: "money-printer-video",
|
||||
client_family: "money-printer",
|
||||
client_app_id: "money-printer",
|
||||
client_app_name: "Money Printer",
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cost,
|
||||
content_summary: Some("topic".to_string()),
|
||||
request_content: Some(json!({ "topic": "topic" }).to_string()),
|
||||
response_content: Some(json!({ "ok": true }).to_string()),
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_and_aggregates_usage() {
|
||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||
@@ -662,6 +942,84 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_comfy_task_success_as_usage_and_proxy_log() {
|
||||
let pool = pool().await;
|
||||
create_proxy_logs(&pool).await;
|
||||
let tracker = UsageTracker::new(Arc::new(pool));
|
||||
|
||||
let billing = tracker
|
||||
.record_comfy_task_success(test_comfy_event(0.35))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(billing["source"], "comfy-native-task");
|
||||
assert_eq!(billing["charged"], true);
|
||||
assert_eq!(billing["totalCost"], 0.35);
|
||||
|
||||
let usage: (i64, f64) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COALESCE(SUM(cost), 0.0) FROM usage_logs WHERE downstream_key_id = 1",
|
||||
)
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(usage, (1, 0.35));
|
||||
|
||||
let key_usage: (i64, f64) =
|
||||
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 1")
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(key_usage, (1, 0.35));
|
||||
|
||||
let proxy_log: (String, f64, String, String) = sqlx::query_as(
|
||||
"SELECT status, estimated_cost, client_app_id, billing_details FROM proxy_logs WHERE downstream_api_key_id = 1",
|
||||
)
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(proxy_log.0, "success");
|
||||
assert_eq!(proxy_log.1, 0.35);
|
||||
assert_eq!(proxy_log.2, "money-printer");
|
||||
assert!(proxy_log.3.contains("\"steps\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_comfy_task_failure_without_debiting_usage() {
|
||||
let pool = pool().await;
|
||||
create_proxy_logs(&pool).await;
|
||||
let tracker = UsageTracker::new(Arc::new(pool));
|
||||
let mut event = test_comfy_event(0.35);
|
||||
event.error_message = Some("Not found".to_string());
|
||||
|
||||
let billing = tracker.record_comfy_task_failure(event).await.unwrap();
|
||||
|
||||
assert_eq!(billing["charged"], false);
|
||||
assert_eq!(billing["totalCost"], 0.0);
|
||||
|
||||
let usage_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM usage_logs WHERE downstream_key_id = 1")
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(usage_count, 0);
|
||||
|
||||
let key_usage: (i64, f64) =
|
||||
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 1")
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(key_usage, (0, 0.0));
|
||||
|
||||
let proxy_log: (String, f64, String) = sqlx::query_as(
|
||||
"SELECT status, estimated_cost, error_message FROM proxy_logs WHERE downstream_api_key_id = 1",
|
||||
)
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(proxy_log, ("failed".to_string(), 0.0, "Not found".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_proxy_logs_to_site_and_model_usage() {
|
||||
let pool = pool().await;
|
||||
|
||||
Reference in New Issue
Block a user