trading-platform-database-v2/ddl/schemas/llm/tables/005_llm_usage_limits.sql
rckrdmrd 62c811be45 [DDL] feat: Sprint 5 - Add llm schema with 5 tables
- conversations: Conversaciones con agente LLM
- conversation_messages: Mensajes individuales
- llm_tools_usage: Registro de uso de herramientas
- llm_proactive_notifications: Notificaciones proactivas
- llm_usage_limits: Limites de uso por usuario/plan VIP

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:13:33 -06:00

359 lines
12 KiB
PL/PgSQL

-- ============================================================================
-- 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;