nxtgauge-gitops/docs/AI_PLANS_IMPLEMENTATION_PLAN.md
Ashwin Kumar Sivakumar 7902b265a9 feat(ai): add AI plans docs, LiteLLM manifests, and infrastructure updates
- Add comprehensive AI plans implementation documentation
- Add LiteLLM gateway Kubernetes manifests
- Update PostgreSQL and Forgejo deployment configs
- Add build-from-binaries script
2026-06-15 06:15:41 +05:30

754 lines
23 KiB
Markdown

# AI Plans Implementation Plan
## Executive Summary
Implement per-user API keys with usage tracking and plan tiers for Ask Ash AI assistant.
## Phase 1: Database Design (Week 1)
### 1.1 New Tables
```sql
-- Migration: 001_add_ai_plans.sql
-- API Keys table (supports rotation)
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash VARCHAR(255) UNIQUE NOT NULL, -- bcrypt hash of key
key_prefix VARCHAR(20) NOT NULL, -- sk-nxtgauge-abc...
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
revoked_reason TEXT NULL
);
-- AI Plans table (plan definitions)
CREATE TABLE ai_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL, -- 'free', 'pro', 'business', 'enterprise'
display_name VARCHAR(100) NOT NULL,
monthly_credits INTEGER NOT NULL,
rate_limit_rpm INTEGER NOT NULL, -- requests per minute
rate_limit_rph INTEGER NOT NULL, -- requests per hour
max_tokens_per_request INTEGER NOT NULL,
price_monthly DECIMAL(10,2) NOT NULL,
features JSONB NOT NULL, -- allowed models, etc.
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- User AI Subscriptions
CREATE TABLE user_ai_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES ai_plans(id),
credits_remaining INTEGER NOT NULL,
credits_used_this_month INTEGER DEFAULT 0,
subscription_status VARCHAR(50) DEFAULT 'active', -- 'active', 'paused', 'cancelled'
current_period_start TIMESTAMP NOT NULL,
current_period_end TIMESTAMP NOT NULL,
cancelled_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- AI Usage Logs (for tracking & billing)
CREATE TABLE ai_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
api_key_id UUID REFERENCES api_keys(id),
model VARCHAR(100) NOT NULL, -- 'askash-fast', 'askash-main', etc.
request_type VARCHAR(100) NOT NULL, -- 'help', 'resume', 'jd', 'cover_letter'
tokens_input INTEGER NOT NULL,
tokens_output INTEGER NOT NULL,
tokens_total INTEGER NOT NULL,
cost_estimate DECIMAL(10,6), -- calculated cost
request_duration_ms INTEGER, -- response time
was_successful BOOLEAN DEFAULT true,
error_message TEXT NULL,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Rate Limit Tracking (Redis alternative)
CREATE TABLE rate_limit_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
window_start TIMESTAMP NOT NULL,
window_end TIMESTAMP NOT NULL,
requests_count INTEGER DEFAULT 0,
UNIQUE(user_id, window_start)
);
-- Insert default plans
INSERT INTO ai_plans (name, display_name, monthly_credits, rate_limit_rpm, rate_limit_rph, max_tokens_per_request, price_monthly, features) VALUES
('free', 'Free', 100, 10, 100, 1000, 0.00, '{"models": ["askash-fast", "help-assistant", "messenger"]}'),
('pro', 'Pro', 1000, 60, 1000, 4000, 9.00, '{"models": ["askash-fast", "askash-main", "help-assistant", "messenger", "recommender", "safety-check"]}'),
('business', 'Business', 5000, 120, 5000, 8000, 29.00, '{"models": ["askash-fast", "askash-main", "jd-generator", "profile-writer", "service-writer", "requirement-writer"]}'),
('enterprise', 'Enterprise', 50000, 0, 0, 32000, 99.00, '{"models": ["all"], "priority": true}');
-- Indexes for performance
CREATE INDEX idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash);
CREATE INDEX idx_user_ai_subscriptions_user_id ON user_ai_subscriptions(user_id);
CREATE INDEX idx_ai_usage_logs_user_id ON ai_usage_logs(user_id);
CREATE INDEX idx_ai_usage_logs_created_at ON ai_usage_logs(created_at);
CREATE INDEX idx_ai_usage_logs_model ON ai_usage_logs(model);
```
### 1.2 Migration Strategy
```bash
# Run migrations
sqlx migrate run --source ./migrations
# Or manual SQL execution
psql $DATABASE_URL < migrations/001_add_ai_plans.sql
```
## Phase 2: Core Implementation (Week 1-2)
### 2.1 Models/DTOs
```rust
// src/models/ai_plan.rs
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct AiPlan {
pub id: Uuid,
pub name: String,
pub display_name: String,
pub monthly_credits: i32,
pub rate_limit_rpm: i32,
pub rate_limit_rph: i32,
pub max_tokens_per_request: i32,
pub price_monthly: f64,
pub features: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct ApiKey {
pub id: Uuid,
pub user_id: Uuid,
pub key_hash: String,
pub key_prefix: String,
pub is_active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct UserAiSubscription {
pub id: Uuid,
pub user_id: Uuid,
pub plan_id: Uuid,
pub credits_remaining: i32,
pub credits_used_this_month: i32,
pub subscription_status: String,
pub current_period_start: chrono::DateTime<chrono::Utc>,
pub current_period_end: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct AiUsageLog {
pub id: Uuid,
pub user_id: Uuid,
pub api_key_id: Option<Uuid>,
pub model: String,
pub request_type: String,
pub tokens_input: i32,
pub tokens_output: i32,
pub tokens_total: i32,
pub cost_estimate: Option<f64>,
pub request_duration_ms: Option<i32>,
pub was_successful: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
```
### 2.2 API Key Generation Service
```rust
// src/services/api_key_service.rs
use bcrypt::{hash, verify, DEFAULT_COST};
use rand::{distributions::Alphanumeric, Rng};
use uuid::Uuid;
pub struct ApiKeyService;
impl ApiKeyService {
/// Generate a new API key
/// Returns: (full_key, key_hash, key_prefix)
pub fn generate_key(user_id: Uuid) -> (String, String, String) {
let prefix = "sk-nxtgauge";
let user_part = user_id.to_string().split('-').next().unwrap_or("");
let random_suffix: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(24)
.map(char::from)
.collect();
let full_key = format!("{}-{}-{}", prefix, user_part, random_suffix);
let key_prefix = format!("{}-{}-", prefix, user_part);
// Hash for storage (use first 8 chars as salt identifier)
let key_hash = hash(&full_key, DEFAULT_COST).unwrap();
(full_key, key_hash, key_prefix)
}
/// Validate an API key against hash
pub fn validate_key(provided_key: &str, stored_hash: &str) -> bool {
verify(provided_key, stored_hash).unwrap_or(false)
}
/// Extract user ID from API key (without validation)
pub fn extract_user_id_from_key(key: &str) -> Option<String> {
let parts: Vec<&str> = key.split('-').collect();
if parts.len() >= 3 && parts[0] == "sk" && parts[1] == "nxtgauge" {
Some(parts[2].to_string())
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_key() {
let user_id = Uuid::new_v4();
let (full_key, hash, prefix) = ApiKeyService::generate_key(user_id);
assert!(full_key.starts_with("sk-nxtgauge-"));
assert_eq!(full_key.len(), 45); // sk-nxtgauge- + 8 + - + 24
assert!(ApiKeyService::validate_key(&full_key, &hash));
}
}
```
### 2.3 Usage Tracking Service
```rust
// src/services/ai_usage_service.rs
use sqlx::PgPool;
use uuid::Uuid;
use chrono::{DateTime, Utc, Duration};
pub struct AiUsageService {
db: PgPool,
}
impl AiUsageService {
pub fn new(db: PgPool) -> Self {
Self { db }
}
/// Check if user has credits and rate limit allows request
pub async fn can_make_request(
&self,
user_id: Uuid,
model: &str,
) -> Result<RequestAllowance, AiError> {
// Get user's subscription
let subscription = sqlx::query_as::<_, UserAiSubscription>(
"SELECT * FROM user_ai_subscriptions WHERE user_id = $1"
)
.bind(user_id)
.fetch_optional(&self.db)
.await?;
let subscription = subscription.ok_or(AiError::NoSubscription)?;
// Check subscription status
if subscription.subscription_status != "active" {
return Err(AiError::SubscriptionInactive);
}
// Check credits
if subscription.credits_remaining <= 0 {
return Err(AiError::InsufficientCredits);
}
// Get plan details
let plan = sqlx::query_as::<_, AiPlan>(
"SELECT * FROM ai_plans WHERE id = $1"
)
.bind(subscription.plan_id)
.fetch_one(&self.db)
.await?;
// Check rate limit (current window)
let window_start = Utc::now() - Duration::minutes(1);
let recent_requests: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ai_usage_logs
WHERE user_id = $1 AND created_at > $2"
)
.bind(user_id)
.bind(window_start)
.fetch_one(&self.db)
.await?;
if recent_requests >= plan.rate_limit_rpm as i64 {
return Err(AiError::RateLimitExceeded);
}
Ok(RequestAllowance {
user_id,
plan,
subscription,
remaining_requests: plan.rate_limit_rpm - recent_requests as i32,
})
}
/// Log AI usage and deduct credits
pub async fn log_usage(
&self,
user_id: Uuid,
api_key_id: Option<Uuid>,
model: &str,
request_type: &str,
tokens_input: i32,
tokens_output: i32,
duration_ms: i32,
success: bool,
) -> Result<(), AiError> {
let tokens_total = tokens_input + tokens_output;
// Calculate cost (example pricing)
let cost = match model {
"askash-fast" | "help-assistant" | "messenger" => tokens_total as f64 * 0.000001, // $0.001 per 1K tokens
"askash-main" | "jd-generator" | "profile-writer" => tokens_total as f64 * 0.000005, // $0.005 per 1K tokens
_ => tokens_total as f64 * 0.000005,
};
// Insert usage log
sqlx::query(
"INSERT INTO ai_usage_logs
(user_id, api_key_id, model, request_type, tokens_input, tokens_output,
tokens_total, cost_estimate, request_duration_ms, was_successful)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
)
.bind(user_id)
.bind(api_key_id)
.bind(model)
.bind(request_type)
.bind(tokens_input)
.bind(tokens_output)
.bind(tokens_total)
.bind(cost)
.bind(duration_ms)
.bind(success)
.execute(&self.db)
.await?;
// Deduct credits (1 credit per 1000 tokens, minimum 1)
let credits_to_deduct = ((tokens_total as f64 / 1000.0).ceil() as i32).max(1);
sqlx::query(
"UPDATE user_ai_subscriptions
SET credits_remaining = credits_remaining - $1,
credits_used_this_month = credits_used_this_month + $1,
updated_at = NOW()
WHERE user_id = $2"
)
.bind(credits_to_deduct)
.bind(user_id)
.execute(&self.db)
.await?;
Ok(())
}
/// Get usage statistics for user
pub async fn get_user_usage(
&self,
user_id: Uuid,
start_date: DateTime<Utc>,
end_date: DateTime<Utc>,
) -> Result<UsageStats, AiError> {
let stats = sqlx::query_as::<_, UsageStats>(
"SELECT
COUNT(*) as total_requests,
SUM(tokens_input) as total_tokens_input,
SUM(tokens_output) as total_tokens_output,
SUM(tokens_total) as total_tokens,
SUM(cost_estimate) as total_cost,
COUNT(CASE WHEN was_successful = false THEN 1 END) as failed_requests
FROM ai_usage_logs
WHERE user_id = $1 AND created_at BETWEEN $2 AND $3"
)
.bind(user_id)
.bind(start_date)
.bind(end_date)
.fetch_one(&self.db)
.await?;
Ok(stats)
}
}
#[derive(Debug)]
pub struct RequestAllowance {
pub user_id: Uuid,
pub plan: AiPlan,
pub subscription: UserAiSubscription,
pub remaining_requests: i32,
}
#[derive(Debug)]
pub struct UsageStats {
pub total_requests: i64,
pub total_tokens_input: i64,
pub total_tokens_output: i64,
pub total_tokens: i64,
pub total_cost: Option<f64>,
pub failed_requests: i64,
}
#[derive(Debug, thiserror::Error)]
pub enum AiError {
#[error("No subscription found")]
NoSubscription,
#[error("Subscription inactive")]
SubscriptionInactive,
#[error("Insufficient credits")]
InsufficientCredits,
#[error("Rate limit exceeded")]
RateLimitExceeded,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
}
```
## Phase 3: API Endpoints (Week 2)
### 3.1 AI Controller
```rust
// src/controllers/ai_controller.rs
use actix_web::{web, HttpResponse, HttpRequest};
use crate::services::AiUsageService;
use crate::middleware::AiAuth;
pub fn ai_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api/v1/ai")
// These require API key auth
.route("/chat", web::post().to(chat_completion))
.route("/usage", web::get().to(get_usage))
// These require user JWT auth
.route("/keys", web::post().to(generate_api_key))
.route("/keys", web::get().to(list_api_keys))
.route("/keys/{key_id}", web::delete().to(revoke_api_key))
.route("/upgrade", web::post().to(upgrade_plan))
);
}
/// Main chat endpoint (requires API key)
async fn chat_completion(
req: HttpRequest,
body: web::Json<ChatRequest>,
usage_service: web::Data<AiUsageService>,
litellm_client: web::Data<LitellmClient>,
) -> HttpResponse {
let start_time = std::time::Instant::now();
// Extract API key from header
let api_key = match extract_api_key(&req) {
Some(key) => key,
None => return HttpResponse::Unauthorized().json(ErrorResponse {
error: "Missing API key".to_string(),
}),
};
// Validate and get user info
let (user_id, api_key_id, plan) = match validate_api_key(&api_key).await {
Ok(info) => info,
Err(e) => return HttpResponse::Unauthorized().json(ErrorResponse {
error: e.to_string(),
}),
};
// Check if user can make request
let allowance = match usage_service.can_make_request(user_id, &body.model).await {
Ok(a) => a,
Err(AiError::InsufficientCredits) => {
return HttpResponse::PaymentRequired().json(ErrorResponse {
error: "Insufficient credits. Please upgrade your plan.".to_string(),
});
}
Err(AiError::RateLimitExceeded) => {
return HttpResponse::TooManyRequests().json(ErrorResponse {
error: "Rate limit exceeded. Please slow down.".to_string(),
});
}
Err(e) => return HttpResponse::InternalServerError().json(ErrorResponse {
error: e.to_string(),
}),
};
// Forward to LiteLLM
let litellm_response = match litellm_client.chat_completion(&body).await {
Ok(resp) => resp,
Err(e) => {
// Log failed request
let _ = usage_service.log_usage(
user_id, api_key_id, &body.model, &body.request_type,
0, 0, start_time.elapsed().as_millis() as i32, false,
).await;
return HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("LiteLLM error: {}", e),
});
}
};
// Log successful usage
let duration_ms = start_time.elapsed().as_millis() as i32;
let tokens_input = litellm_response.usage.prompt_tokens;
let tokens_output = litellm_response.usage.completion_tokens;
let _ = usage_service.log_usage(
user_id, api_key_id, &body.model, &body.request_type,
tokens_input, tokens_output, duration_ms, true,
).await;
// Return response with headers
HttpResponse::Ok()
.insert_header(("X-RateLimit-Remaining", allowance.remaining_requests.to_string()))
.insert_header(("X-Credits-Remaining", allowance.subscription.credits_remaining.to_string()))
.json(litellm_response)
}
/// Get user's usage statistics
async fn get_usage(
auth: web::ReqData<UserClaims>, // JWT auth
usage_service: web::Data<AiUsageService>,
query: web::Query<UsageQuery>,
) -> HttpResponse {
let start_date = query.start_date.unwrap_or_else(|| {
Utc::now() - Duration::days(30)
});
let end_date = query.end_date.unwrap_or(Utc::now());
match usage_service.get_user_usage(auth.user_id, start_date, end_date).await {
Ok(stats) => HttpResponse::Ok().json(stats),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse {
error: e.to_string(),
}),
}
}
/// Generate new API key
async fn generate_api_key(
auth: web::ReqData<UserClaims>,
body: web::Json<GenerateKeyRequest>,
api_key_service: web::Data<ApiKeyService>,
) -> HttpResponse {
let (full_key, key_hash, key_prefix) = ApiKeyService::generate_key(auth.user_id);
// Save to database
match api_key_service.save_key(auth.user_id, &key_hash, &key_prefix).await {
Ok(_) => HttpResponse::Ok().json(GenerateKeyResponse {
api_key: full_key,
prefix: key_prefix,
created_at: Utc::now(),
}),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse {
error: e.to_string(),
}),
}
}
```
## Phase 4: Middleware (Week 2)
### 4.1 API Key Extraction
```rust
// src/middleware/ai_auth.rs
use actix_web::{dev::ServiceRequest, Error, HttpMessage};
use actix_web::dev::{Transform, Service};
use futures::future::{LocalBoxFuture, ok, Ready};
use std::task::{Context, Poll};
pub struct AiAuth;
impl<S, B> Transform<S, ServiceRequest> for AiAuth
where
S: Service<ServiceRequest, Response = actix_web::dev::ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = S::Response;
type Error = S::Error;
type Transform = AiAuthMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(AiAuthMiddleware { service })
}
}
pub struct AiAuthMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for AiAuthMiddleware<S>
where
S: Service<ServiceRequest, Response = actix_web::dev::ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
// Extract API key from Authorization header
if let Some(auth_header) = req.headers().get("Authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if auth_str.starts_with("Bearer ") {
let api_key = &auth_str[7..];
// Store in request extensions for later use
req.extensions_mut().insert(api_key.to_string());
}
}
}
let fut = self.service.call(req);
Box::pin(async move {
fut.await
})
}
}
```
## Phase 5: Integration (Week 3)
### 5.1 Frontend Changes
```typescript
// Frontend API client
class AiClient {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async chatCompletion(model: string, message: string): Promise<AIResponse> {
const response = await fetch('/api/v1/ai/chat', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: message }],
request_type: 'help',
}),
});
// Check for credit/rate limit headers
const creditsRemaining = response.headers.get('X-Credits-Remaining');
const rateLimitRemaining = response.headers.get('X-RateLimit-Remaining');
if (response.status === 402) {
throw new Error('Insufficient credits. Please upgrade.');
}
if (response.status === 429) {
throw new Error('Rate limit exceeded. Please slow down.');
}
return response.json();
}
async getUsageStats(): Promise<UsageStats> {
const response = await fetch('/api/v1/ai/usage', {
headers: {
'Authorization': `Bearer ${this.jwtToken}`, // JWT for authenticated endpoints
},
});
return response.json();
}
}
```
## Phase 6: Testing & Deployment (Week 3-4)
### 6.1 Testing Checklist
- [ ] Unit tests for API key generation
- [ ] Unit tests for usage tracking
- [ ] Integration tests for rate limiting
- [ ] Load tests for concurrent requests
- [ ] Security tests (key validation, SQL injection)
### 6.2 Migration Steps
```bash
# 1. Backup database
pg_dump $DATABASE_URL > backup_pre_ai_plans.sql
# 2. Run migrations
sqlx migrate run
# 3. Seed default plans
psql $DATABASE_URL < seed_plans.sql
# 4. Deploy new backend version
cargo build --release
# 5. Generate API keys for existing users
./scripts/migrate_existing_users.sh
# 6. Verify deployment
./scripts/verify_ai_plans.sh
```
## Timeline
| Week | Phase | Deliverables |
|------|-------|--------------|
| Week 1 | Database + Core Services | Tables, models, services |
| Week 2 | API + Middleware | Endpoints, auth, rate limiting |
| Week 3 | Integration + Frontend | React components, testing |
| Week 4 | Testing + Deployment | Load tests, monitoring, docs |
## Cost Estimation
| Component | Cost |
|-----------|------|
| Database storage | ~$5/mo (10GB) |
| Compute (tracking) | Minimal (async) |
| **Total additional** | **~$5/mo** |
## Next Steps
1. **Review this plan** - Any changes needed?
2. **Approve database schema** - Are all fields needed?
3. **Set priority** - Which features are must-have for MVP?
Ready to start Phase 1?