-- ============================================================================ -- SCHEMA: llm -- TABLE: llm_usage_limits -- DESCRIPTION: Limites de uso del agente LLM por usuario/plan -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 5 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Enum para periodo de reset DO $$ BEGIN CREATE TYPE llm.reset_period AS ENUM ( 'hourly', 'daily', 'weekly', 'monthly', 'never' -- Sin reset (lifetime limit) ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla de Limites de Uso CREATE TABLE IF NOT EXISTS llm.llm_usage_limits ( -- Identificadores id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, -- Plan VIP asociado vip_plan_id UUID, -- FK a vip.plans si existe vip_level VARCHAR(20), -- 'free', 'basic', 'pro', 'enterprise' -- Limites de mensajes messages_limit INTEGER NOT NULL DEFAULT 100, -- Mensajes por periodo messages_used INTEGER NOT NULL DEFAULT 0, messages_reset_period llm.reset_period NOT NULL DEFAULT 'daily', messages_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 day', -- Limites de tokens tokens_limit INTEGER NOT NULL DEFAULT 100000, -- Tokens por periodo tokens_used INTEGER NOT NULL DEFAULT 0, tokens_reset_period llm.reset_period NOT NULL DEFAULT 'monthly', tokens_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 month', -- Limites de herramientas tools_limit INTEGER NOT NULL DEFAULT 50, -- Llamadas a herramientas por periodo tools_used INTEGER NOT NULL DEFAULT 0, tools_reset_period llm.reset_period NOT NULL DEFAULT 'daily', tools_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 day', -- Limites de conversaciones conversations_limit INTEGER, -- Conversaciones activas max (NULL = unlimited) conversations_active INTEGER NOT NULL DEFAULT 0, -- Modelos permitidos allowed_models llm.llm_model[] DEFAULT ARRAY['gpt-4o-mini']::llm.llm_model[], default_model llm.llm_model NOT NULL DEFAULT 'gpt-4o-mini', -- Herramientas permitidas allowed_tools VARCHAR(100)[], -- NULL = todas blocked_tools VARCHAR(100)[], -- Herramientas bloqueadas -- Features can_use_vision BOOLEAN NOT NULL DEFAULT FALSE, can_use_voice BOOLEAN NOT NULL DEFAULT FALSE, can_share_conversations BOOLEAN NOT NULL DEFAULT TRUE, can_export_conversations BOOLEAN NOT NULL DEFAULT TRUE, can_use_code_interpreter BOOLEAN NOT NULL DEFAULT FALSE, -- Rate limiting requests_per_minute INTEGER DEFAULT 10, requests_per_hour INTEGER DEFAULT 100, current_minute_requests INTEGER NOT NULL DEFAULT 0, current_hour_requests INTEGER NOT NULL DEFAULT 0, minute_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 minute', hour_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 hour', -- Costo monthly_budget_usd DECIMAL(10, 2), -- Presupuesto mensual monthly_spent_usd DECIMAL(10, 6) NOT NULL DEFAULT 0, budget_alert_threshold DECIMAL(5, 2) DEFAULT 80, -- % para alerta budget_alert_sent BOOLEAN NOT NULL DEFAULT FALSE, -- Overage (exceso) allow_overage BOOLEAN NOT NULL DEFAULT FALSE, overage_rate_multiplier DECIMAL(5, 2) DEFAULT 1.5, -- Multiplicador de costo en overage overage_limit_percent DECIMAL(5, 2) DEFAULT 20, -- % max de overage permitido -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, is_suspended BOOLEAN NOT NULL DEFAULT FALSE, suspension_reason TEXT, suspended_at TIMESTAMPTZ, -- Notificaciones notify_at_percent INTEGER[] DEFAULT ARRAY[50, 80, 100], last_notification_percent INTEGER, -- Metadata custom_limits JSONB DEFAULT '{}'::JSONB, -- Limites personalizados metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT usage_limits_user_unique UNIQUE (user_id), CONSTRAINT usage_limits_valid_percents CHECK ( budget_alert_threshold BETWEEN 0 AND 100 AND overage_limit_percent BETWEEN 0 AND 100 ) ); COMMENT ON TABLE llm.llm_usage_limits IS 'Limites de uso del agente LLM por usuario, vinculado a planes VIP'; COMMENT ON COLUMN llm.llm_usage_limits.allow_overage IS 'Si TRUE, permite uso adicional con costo extra cuando se alcanza el limite'; -- Indices CREATE INDEX IF NOT EXISTS idx_usage_limits_tenant ON llm.llm_usage_limits(tenant_id); CREATE INDEX IF NOT EXISTS idx_usage_limits_user ON llm.llm_usage_limits(user_id); CREATE INDEX IF NOT EXISTS idx_usage_limits_vip ON llm.llm_usage_limits(vip_level); CREATE INDEX IF NOT EXISTS idx_usage_limits_reset ON llm.llm_usage_limits(messages_reset_at, tokens_reset_at); CREATE INDEX IF NOT EXISTS idx_usage_limits_suspended ON llm.llm_usage_limits(is_suspended) WHERE is_suspended = TRUE; CREATE INDEX IF NOT EXISTS idx_usage_limits_budget_alert ON llm.llm_usage_limits(monthly_budget_usd, monthly_spent_usd) WHERE monthly_budget_usd IS NOT NULL; -- Trigger para updated_at DROP TRIGGER IF EXISTS usage_limits_updated_at ON llm.llm_usage_limits; CREATE TRIGGER usage_limits_updated_at BEFORE UPDATE ON llm.llm_usage_limits FOR EACH ROW EXECUTE FUNCTION llm.update_llm_timestamp(); -- Funcion para verificar y resetear limites CREATE OR REPLACE FUNCTION llm.check_and_reset_limits(p_user_id UUID) RETURNS llm.llm_usage_limits AS $$ DECLARE v_limits llm.llm_usage_limits; BEGIN SELECT * INTO v_limits FROM llm.llm_usage_limits WHERE user_id = p_user_id FOR UPDATE; IF NOT FOUND THEN RETURN NULL; END IF; -- Reset mensajes si es necesario IF v_limits.messages_reset_at <= NOW() THEN UPDATE llm.llm_usage_limits SET messages_used = 0, messages_reset_at = NOW() + ( CASE messages_reset_period WHEN 'hourly' THEN INTERVAL '1 hour' WHEN 'daily' THEN INTERVAL '1 day' WHEN 'weekly' THEN INTERVAL '1 week' WHEN 'monthly' THEN INTERVAL '1 month' ELSE INTERVAL '100 years' END ) WHERE user_id = p_user_id; END IF; -- Reset tokens si es necesario IF v_limits.tokens_reset_at <= NOW() THEN UPDATE llm.llm_usage_limits SET tokens_used = 0, tokens_reset_at = NOW() + ( CASE tokens_reset_period WHEN 'hourly' THEN INTERVAL '1 hour' WHEN 'daily' THEN INTERVAL '1 day' WHEN 'weekly' THEN INTERVAL '1 week' WHEN 'monthly' THEN INTERVAL '1 month' ELSE INTERVAL '100 years' END ) WHERE user_id = p_user_id; END IF; -- Reset herramientas si es necesario IF v_limits.tools_reset_at <= NOW() THEN UPDATE llm.llm_usage_limits SET tools_used = 0, tools_reset_at = NOW() + ( CASE tools_reset_period WHEN 'hourly' THEN INTERVAL '1 hour' WHEN 'daily' THEN INTERVAL '1 day' WHEN 'weekly' THEN INTERVAL '1 week' WHEN 'monthly' THEN INTERVAL '1 month' ELSE INTERVAL '100 years' END ) WHERE user_id = p_user_id; END IF; -- Reset rate limits IF v_limits.minute_reset_at <= NOW() THEN UPDATE llm.llm_usage_limits SET current_minute_requests = 0, minute_reset_at = NOW() + INTERVAL '1 minute' WHERE user_id = p_user_id; END IF; IF v_limits.hour_reset_at <= NOW() THEN UPDATE llm.llm_usage_limits SET current_hour_requests = 0, hour_reset_at = NOW() + INTERVAL '1 hour' WHERE user_id = p_user_id; END IF; -- Retornar limites actualizados SELECT * INTO v_limits FROM llm.llm_usage_limits WHERE user_id = p_user_id; RETURN v_limits; END; $$ LANGUAGE plpgsql; -- Funcion para verificar si puede enviar mensaje CREATE OR REPLACE FUNCTION llm.can_send_message(p_user_id UUID) RETURNS TABLE ( allowed BOOLEAN, reason TEXT, messages_remaining INTEGER, tokens_remaining INTEGER ) AS $$ DECLARE v_limits llm.llm_usage_limits; BEGIN v_limits := llm.check_and_reset_limits(p_user_id); IF v_limits IS NULL THEN RETURN QUERY SELECT FALSE, 'User limits not configured'::TEXT, 0, 0; RETURN; END IF; IF v_limits.is_suspended THEN RETURN QUERY SELECT FALSE, v_limits.suspension_reason, 0, 0; RETURN; END IF; IF NOT v_limits.is_active THEN RETURN QUERY SELECT FALSE, 'Account inactive'::TEXT, 0, 0; RETURN; END IF; IF v_limits.messages_used >= v_limits.messages_limit AND NOT v_limits.allow_overage THEN RETURN QUERY SELECT FALSE, 'Message limit reached'::TEXT, 0, v_limits.tokens_limit - v_limits.tokens_used; RETURN; END IF; IF v_limits.current_minute_requests >= v_limits.requests_per_minute THEN RETURN QUERY SELECT FALSE, 'Rate limit: too many requests per minute'::TEXT, v_limits.messages_limit - v_limits.messages_used, v_limits.tokens_limit - v_limits.tokens_used; RETURN; END IF; RETURN QUERY SELECT TRUE, NULL::TEXT, v_limits.messages_limit - v_limits.messages_used, v_limits.tokens_limit - v_limits.tokens_used; END; $$ LANGUAGE plpgsql; -- Funcion para incrementar uso CREATE OR REPLACE FUNCTION llm.increment_usage( p_user_id UUID, p_messages INTEGER DEFAULT 1, p_tokens INTEGER DEFAULT 0, p_tools INTEGER DEFAULT 0, p_cost_usd DECIMAL DEFAULT 0 ) RETURNS VOID AS $$ BEGIN UPDATE llm.llm_usage_limits SET messages_used = messages_used + p_messages, tokens_used = tokens_used + p_tokens, tools_used = tools_used + p_tools, monthly_spent_usd = monthly_spent_usd + p_cost_usd, current_minute_requests = current_minute_requests + 1, current_hour_requests = current_hour_requests + 1 WHERE user_id = p_user_id; END; $$ LANGUAGE plpgsql; -- Vista de uso actual CREATE OR REPLACE VIEW llm.v_current_usage AS SELECT user_id, vip_level, messages_used, messages_limit, ROUND((messages_used::DECIMAL / NULLIF(messages_limit, 0) * 100), 1) AS messages_percent, tokens_used, tokens_limit, ROUND((tokens_used::DECIMAL / NULLIF(tokens_limit, 0) * 100), 1) AS tokens_percent, tools_used, tools_limit, monthly_spent_usd, monthly_budget_usd, messages_reset_at, tokens_reset_at, is_active, is_suspended FROM llm.llm_usage_limits; -- Vista de usuarios cerca del limite CREATE OR REPLACE VIEW llm.v_users_near_limit AS SELECT user_id, vip_level, messages_used, messages_limit, ROUND((messages_used::DECIMAL / NULLIF(messages_limit, 0) * 100), 1) AS messages_percent, tokens_used, tokens_limit, ROUND((tokens_used::DECIMAL / NULLIF(tokens_limit, 0) * 100), 1) AS tokens_percent FROM llm.llm_usage_limits WHERE (messages_used::DECIMAL / NULLIF(messages_limit, 0) >= 0.8) OR (tokens_used::DECIMAL / NULLIF(tokens_limit, 0) >= 0.8) ORDER BY GREATEST( messages_used::DECIMAL / NULLIF(messages_limit, 0), tokens_used::DECIMAL / NULLIF(tokens_limit, 0) ) DESC; -- RLS Policies ALTER TABLE llm.llm_usage_limits ENABLE ROW LEVEL SECURITY; CREATE POLICY usage_limits_tenant ON llm.llm_usage_limits FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); CREATE POLICY usage_limits_user ON llm.llm_usage_limits FOR SELECT USING (user_id = current_setting('app.current_user_id', true)::UUID); -- Grants GRANT SELECT, INSERT, UPDATE ON llm.llm_usage_limits TO trading_app; GRANT SELECT ON llm.llm_usage_limits TO trading_readonly; GRANT SELECT ON llm.v_current_usage TO trading_app; GRANT SELECT ON llm.v_users_near_limit TO trading_app; GRANT EXECUTE ON FUNCTION llm.check_and_reset_limits TO trading_app; GRANT EXECUTE ON FUNCTION llm.can_send_message TO trading_app; GRANT EXECUTE ON FUNCTION llm.increment_usage TO trading_app;