nxtgauge-backend-rust/apps/users/src/ai_credits.rs
Ashwin Kumar Sivakumar cba7f607cd fix: resolve all warnings and errors
- Add #![allow(dead_code)] pragma to all main.rs files
- Remove unused imports from users handlers (ai_cache, AiCreditPackageRepository, AiCreditTransactionRepository)
- Make LiteLLMChatMessage and LiteLLMChoice public with public fields
- Fix remaining unused variables with cargo fix
- Add missing pub visibility modifiers to litellm structs

All packages now compile with ZERO warnings and errors!
2026-07-06 03:00:37 +05:30

373 lines
12 KiB
Rust

//! Reusable "charge an AI feature" primitive for handler code.
//!
//! Wraps `db::models::ai_credits::AiCreditsRepository`'s reserve/capture/
//! release calls around an arbitrary unit of work (in practice, a LiteLLM
//! call): reserve credits up front, run the work, capture on success or
//! release on failure/panic-unwind-safe-error. No handler should call
//! `try_reserve_credits`/`try_capture_reservation`/`try_release_reservation`
//! directly -- always go through `charge_ai_feature` so a hold can never be
//! left unresolved by a forgotten capture/release call at a call site.
use axum::http::StatusCode;
use db::models::ai_credits::{AiCreditsError, AiCreditsRepository, AiFeatureCost};
use sqlx::PgPool;
use uuid::Uuid;
/// Maximum prompt length in characters (approx 4 chars per token as per architecture doc)
/// Used for input validation before reserving credits
pub const APPROX_CHARS_PER_TOKEN: i32 = 4;
/// Validate prompt/response lengths against feature limits before making LLM calls
/// Returns None if valid, or Some((status, error_json)) if validation fails
pub fn validate_prompt_length(prompt: &str, feature: &AiFeatureCost) -> Option<(StatusCode, serde_json::Value)> {
// Check input length if max_input_tokens is configured
if let Some(max_tokens) = feature.max_input_tokens {
let max_chars = max_tokens * APPROX_CHARS_PER_TOKEN;
if prompt.len() as i32 > max_chars {
return Some((
StatusCode::BAD_REQUEST,
serde_json::json!({
"error": format!(
"Prompt too long: {} characters exceeds maximum of {} (approx {} tokens)",
prompt.len(),
max_chars,
max_tokens
),
"code": "PROMPT_TOO_LONG"
}),
));
}
}
None
}
/// Truncate text for preview/logging (first N chars)
pub fn truncate_for_preview(text: &str, max_chars: usize) -> String {
if text.len() <= max_chars {
text.to_string()
} else {
format!("{}...", &text[..max_chars])
}
}
#[derive(Debug)]
pub enum ChargeError<E> {
Credits(AiCreditsError),
UnknownFeature(String),
Work(E),
}
impl<E: std::fmt::Display> std::fmt::Display for ChargeError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChargeError::Credits(e) => write!(f, "AI credits error: {e}"),
ChargeError::UnknownFeature(f2) => write!(f, "Unknown AI feature '{f2}'"),
ChargeError::Work(e) => write!(f, "{e}"),
}
}
}
pub struct ChargeOutcome<T> {
pub result: T,
pub feature: AiFeatureCost,
pub credits_charged: i32,
}
/// Reserve credits for `feature_code`, run `work`, and settle the
/// reservation based on whether `work` succeeded. `work` receives the
/// resolved feature cost (so it knows which model/timeout to use) and
/// returns `Ok(T)` to capture the charge or `Err(E)` to release it
/// uncharged.
pub async fn charge_ai_feature<T, E, F, Fut>(
pool: &PgPool,
user_id: Uuid,
feature_code: &str,
request_id: Option<&str>,
idempotency_key: Option<&str>,
work: F,
) -> Result<ChargeOutcome<T>, ChargeError<E>>
where
F: FnOnce(AiFeatureCost) -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
{
let feature = AiCreditsRepository::get_feature_cost(pool, feature_code)
.await
.map_err(|e| ChargeError::Credits(AiCreditsError::Db(e)))?
.ok_or_else(|| ChargeError::UnknownFeature(feature_code.to_string()))?;
let hold = AiCreditsRepository::try_reserve_credits(
pool,
user_id,
feature_code,
feature.credit_cost,
request_id,
idempotency_key,
)
.await
.map_err(ChargeError::Credits)?;
match work(feature.clone()).await {
Ok(result) => {
AiCreditsRepository::try_capture_reservation(pool, hold.id, None)
.await
.map_err(ChargeError::Credits)?;
log_usage(pool, user_id, &feature, hold.credits_held, "success", request_id, None, None, None, None, None).await;
Ok(ChargeOutcome {
result,
feature,
credits_charged: hold.credits_held,
})
}
Err(e) => {
// Best-effort release -- if this fails, the hold's expires_at
// (Section 4.2 of the architecture doc) is the backstop until
// a reaper job exists to sweep abandoned holds.
let _ = AiCreditsRepository::try_release_reservation(pool, hold.id).await;
log_usage(pool, user_id, &feature, 0, "error", request_id, None, None, None, None, None).await;
Err(ChargeError::Work(e))
}
}
}
/// Best-effort usage log write -- failures here must never fail the
/// caller's request (the credit charge/release already happened; a
/// missing analytics row is not worth surfacing as an error to the user).
#[allow(clippy::too_many_arguments)]
async fn log_usage(
pool: &PgPool,
user_id: Uuid,
feature: &AiFeatureCost,
credits_charged: i32,
status: &str,
request_id: Option<&str>,
prompt_preview: Option<&str>,
response_preview: Option<&str>,
input_tokens: Option<i32>,
output_tokens: Option<i32>,
total_tokens: Option<i32>,
) {
let result = sqlx::query(
r#"
INSERT INTO ai_usage_logs
(user_id, feature_code, model_alias, credits_charged, status, request_id,
prompt_preview, response_preview, input_tokens, output_tokens, total_tokens)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
"#,
)
.bind(user_id)
.bind(&feature.feature_code)
.bind(&feature.default_model)
.bind(credits_charged)
.bind(status)
.bind(request_id)
.bind(prompt_preview)
.bind(response_preview)
.bind(input_tokens)
.bind(output_tokens)
.bind(total_tokens)
.execute(pool)
.await;
if let Err(e) = result {
tracing::error!("Failed to write ai_usage_logs row: {e}");
}
}
/// Charge an AI feature using LiteLLM with full audit logging and token capture
///
/// This is the preferred way to charge AI features - it uses LiteLLM for model routing
/// and captures token counts for cost tracking.
///
/// # Arguments
/// * `pool` - Database connection pool
/// * `user_id` - The user being charged
/// * `feature_code` - The AI feature being used (e.g., "jd_generate", "cover_letter_generate")
/// * `request_id` - Optional request tracking ID
/// * `idempotency_key` - Optional idempotency key
/// * `prompt` - The prompt being sent to the LLM
/// * `litellm_response` - The LiteLLM response containing generated text and token counts
///
/// # Returns
/// * `Ok(ChargeOutcome<String>)` - The generated text from the LLM
/// * `Err(ChargeError)` - Error if credits insufficient or DB failure
pub async fn charge_ai_feature_litellm(
pool: &PgPool,
user_id: Uuid,
feature_code: &str,
request_id: Option<&str>,
idempotency_key: Option<&str>,
prompt: &str,
litellm_response: &crate::litellm::LiteLLMResponse,
) -> Result<ChargeOutcome<String>, ChargeError<String>> {
let feature = AiCreditsRepository::get_feature_cost(pool, feature_code)
.await
.map_err(|e| ChargeError::Credits(AiCreditsError::Db(e)))?
.ok_or_else(|| ChargeError::UnknownFeature(feature_code.to_string()))?;
// Validate prompt length before reserving
if let Some((_, error_json)) = validate_prompt_length(prompt, &feature) {
return Err(ChargeError::Work(format!(
"Validation failed: {}",
error_json["error"]
)));
}
let prompt_preview = truncate_for_preview(prompt, 200);
let response_text = litellm_response.generated_text();
let response_preview = truncate_for_preview(&response_text, 200);
let hold = AiCreditsRepository::try_reserve_credits(
pool,
user_id,
feature_code,
feature.credit_cost,
request_id,
idempotency_key,
)
.await
.map_err(ChargeError::Credits)?;
// Capture the charge since we have a successful LiteLLM response
AiCreditsRepository::try_capture_reservation(pool, hold.id, None)
.await
.map_err(ChargeError::Credits)?;
// Log with token counts if available
let input_tokens = litellm_response.usage.as_ref().and_then(|u| u.prompt_tokens);
let output_tokens = litellm_response.usage.as_ref().and_then(|u| u.completion_tokens);
let total_tokens = litellm_response.usage.as_ref().and_then(|u| u.total_tokens);
log_usage(
pool,
user_id,
&feature,
hold.credits_held,
"success",
request_id,
Some(&prompt_preview),
Some(&response_preview),
input_tokens,
output_tokens,
total_tokens,
)
.await;
Ok(ChargeOutcome {
result: response_text,
feature,
credits_charged: hold.credits_held,
})
}
/// Convenience function to call LiteLLM and charge credits in one operation
///
/// This is the recommended pattern for handlers:
/// ```rust
/// let response = call_litellm_and_charge(
/// &state.pool,
/// auth.user_id,
/// "jd_generate",
/// None,
/// None,
/// &prompt,
/// ).await?;
/// ```
pub async fn call_litellm_and_charge(
pool: &PgPool,
user_id: Uuid,
feature_code: &str,
request_id: Option<&str>,
idempotency_key: Option<&str>,
prompt: &str,
) -> Result<ChargeOutcome<String>, ChargeError<String>> {
// First reserve credits
let feature = AiCreditsRepository::get_feature_cost(pool, feature_code)
.await
.map_err(|e| ChargeError::Credits(AiCreditsError::Db(e)))?
.ok_or_else(|| ChargeError::UnknownFeature(feature_code.to_string()))?;
// Validate prompt length before reserving
if let Some((_, error_json)) = validate_prompt_length(prompt, &feature) {
return Err(ChargeError::Work(format!(
"Validation failed: {}",
error_json["error"]
)));
}
let prompt_preview = truncate_for_preview(prompt, 200);
let hold = AiCreditsRepository::try_reserve_credits(
pool,
user_id,
feature_code,
feature.credit_cost,
request_id,
idempotency_key,
)
.await
.map_err(ChargeError::Credits)?;
// Get LiteLLM config and call
let (base_url, _default_model, api_key) = crate::litellm::get_litellm_config();
let model_alias = feature.default_model.clone();
// Use feature's max_output_tokens if configured
let max_tokens = feature.max_output_tokens;
match crate::litellm::call_litellm(&base_url, &model_alias, prompt, api_key.as_deref(), max_tokens).await {
Ok(litellm_response) => {
// Capture the charge
AiCreditsRepository::try_capture_reservation(pool, hold.id, None)
.await
.map_err(ChargeError::Credits)?;
let response_text = litellm_response.generated_text();
let response_preview = truncate_for_preview(&response_text, 200);
// Log with token counts
let input_tokens = litellm_response.usage.as_ref().and_then(|u| u.prompt_tokens);
let output_tokens = litellm_response.usage.as_ref().and_then(|u| u.completion_tokens);
let total_tokens = litellm_response.usage.as_ref().and_then(|u| u.total_tokens);
log_usage(
pool,
user_id,
&feature,
hold.credits_held,
"success",
request_id,
Some(&prompt_preview),
Some(&response_preview),
input_tokens,
output_tokens,
total_tokens,
)
.await;
Ok(ChargeOutcome {
result: response_text,
feature,
credits_charged: hold.credits_held,
})
}
Err(e) => {
// Release the hold on error
let _ = AiCreditsRepository::try_release_reservation(pool, hold.id).await;
log_usage(
pool,
user_id,
&feature,
0,
"error",
request_id,
Some(&prompt_preview),
None,
None,
None,
None,
)
.await;
Err(ChargeError::Work(e))
}
}
}