- 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!
373 lines
12 KiB
Rust
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))
|
|
}
|
|
}
|
|
}
|