16 KiB
Plan: API pública multi-tenant + monetización por uso
Estado: PLANIFICADO, SIN IMPLEMENTAR. (2026-06-11) Este documento existe para retomar el trabajo en una sesión futura sin re-derivar el diseño. Todo lo de abajo está decidido tras leer el código actual; ningún fichero nuevo se ha escrito aún ni se ha tocado código existente.
1. Contexto y decisión de producto
Decisión tomada con Juan (sesión 2026-06-11):
- Forja sigue en la dirección A: plano de control de gobernanza en runtime
(guardrails + HITL + auditoría delante del modelo). No se hornea seguridad
en pesos (no alignment training); el módulo
training/queda como está (fine-tuning de tarea, stub Azure ML). - Giro de producto: de consola interna a producto consumible por terceros.
Empresas cliente (tenants) consumen por una API pública
/v1un catálogo de agentes gobernados = modelo LLM + esquema de entrada/salida + guardrails- HITL + auditoría. Ese empaquetado es nuestro valor añadido sobre el LLM crudo.
- Monetización por uso: cada invocación a la API se mide y tarifica según el plan del tenant (fee mensual + invocaciones incluidas + precio por invocación extra + precio por tokens).
- Genericidad: el core ya es genérico (los validadores los elige la política; el contenido telco es solo el agente de ejemplo). La genericidad se materializa en la plataforma multi-tenant, no añadiendo más agentes de ejemplo.
2. Arquitectura de la solución
Mismo proceso FastAPI (:8000), tercera cara además de UI y /api:
UI HTMX (operador) │ /api (admin interno, sin auth) │ /v1 (público, API key)
Dos registries nuevos con el patrón existente (registry/yaml_store.py,
YAML versionado): tenants/ y plans/. Un log nuevo append-only:
data/usage.jsonl (eventos de consumo tarificados, estilo CDR).
Decisiones clave (con su porqué)
| Decisión | Razón |
|---|---|
API keys fk_<hex>; en disco solo SHA-256 (api_key_hash); comparación con hmac.compare_digest |
No guardar secretos en YAML; la clave se muestra una sola vez al crear el tenant. |
Auth con HTTPBearer(auto_error=False) de FastAPI |
Botón "Authorize" gratis en /docs — la doc OpenAPI es el escaparate del producto. |
/v1 ejecuta siempre la versión activa del agente (sin parámetro version) |
La promesa de gobernanza: el cliente consume lo promocionado vía draft→active. |
| Eventos de uso tarificados al escribirse (precios congelados en el evento) | Patrón CDR/billing: cambiar tarifas no reescribe el histórico; disputas auditables. |
Fee por invocación se exime mientras n_mes < included_invocations; tokens se facturan siempre |
Permite tarificar en el momento del evento (solo necesita contar el mes en curso). |
hard_limit: true en el plan → HTTP 429 al agotar cuota mensual |
Sandbox gratuito con corte duro; planes de pago pasan a overage. |
Propiedad de ejecuciones vía execution_index.json (campo tenant opcional en cada entrada) |
Cero cambios en el dominio AgentExecution; el índice ya sobrevive a reinicios y nunca se poda. |
Capa pública delega en los handlers internos de api/executions.py |
Reuso total: público = auth + ownership + metering + delegación. Sin lógica duplicada. |
GET /v1/... de otro tenant → 404 (no 403) |
No revelar existencia de ejecuciones ajenas. |
Aprobar/rechazar HITL vía /v1 lo hace el operador del cliente |
El HITL es parte del producto vendible. El resume no llama al LLM → no genera coste. |
Dinero en float con round(x, 6) |
Aceptable para MVP; Decimal/Stripe en roadmap. |
3. Ficheros nuevos (diseño exacto)
3.1 Dominio
core/src/forja_core/domain/plan.py — PlanVersionMeta = VersionMeta (alias, como el resto) y:
class PlanDefinition(BaseModel):
name: str; version: str; owner: str; purpose: str
state: Literal["active", "deprecated"] = "active"
monthly_fee_eur: float = Field(default=0.0, ge=0)
included_invocations: int = Field(default=0, ge=0)
price_per_invocation_eur: float = Field(default=0.0, ge=0) # solo overage
price_per_1k_tokens_in_eur: float = Field(default=0.0, ge=0)
price_per_1k_tokens_out_eur: float = Field(default=0.0, ge=0)
hard_limit: bool = False # True: 429 al agotar included (sandbox)
updated_at: datetime
core/src/forja_core/domain/tenant.py — TenantVersionMeta = VersionMeta y:
class TenantDefinition(BaseModel):
name: str; version: str; owner: str; purpose: str
state: Literal["active", "suspended"] = "active" # suspended → 403 en auth
plan: str
api_key_hash: str # sha256 hex; nunca la clave en claro
contact_email: str
allowed_agents: list[str] = [] # vacío = todos los agentes activos
updated_at: datetime
core/src/forja_core/domain/usage.py:
class UsageEvent(BaseModel): # una invocación facturable (precios congelados)
trace_id: UUID; tenant: str; agent_name: str; agent_version: str
plan: str; status: ExecutionStatus
tokens_in: int = 0; tokens_out: int = 0
invocation_fee_eur: float = 0.0; tokens_fee_eur: float = 0.0; cost_eur: float = 0.0
created_at: datetime
class UsageSummary(BaseModel): # "la factura" del mes
tenant: str; plan: str; period: str # period = "YYYY-MM"
n_invocations: int; tokens_in: int; tokens_out: int
included_invocations: int
usage_cost_eur: float; monthly_fee_eur: float; total_eur: float
3.2 Registries
registry/plan_store.py — FileSystemPlanStore(VersionedYamlStore[PlanDefinition]),
kind="plan", solo lectura: list_plans(), get_plan(name, version=None),
list_versions(name). Calcado de dataset_store.py.
registry/tenant_store.py — FileSystemTenantStore(VersionedYamlStore[TenantDefinition]),
kind="tenant", lectura + escritura:
list_tenants(), get_tenant(), exists(name) -> bool (mira index.yaml),
create_tenant(body, message, author) → self._upsert_version(body.name, body, body.version, message, author, set_active=True).
registry/factory.py — añadir build_tenant_store(settings) y
build_plan_store(settings).
3.3 Config y deps
config.py — bajo "Persistencia" añadir:
tenants_dir: Path = Field(default=Path("./tenants")) y
plans_dir: Path = Field(default=Path("./plans")).
api/deps.py — get_tenant_store / get_plan_store con @lru_cache(maxsize=1)
- aliases
TenantStoreDep/PlanStoreDep. (Los tests deben añadirlos a suscache_clear().)
3.4 Auth — api/auth.py
API_KEY_PREFIX = "fk_"
def generate_api_key() -> str: ... # fk_ + secrets.token_hex(20)
def hash_api_key(key: str) -> str: ... # hashlib.sha256(...).hexdigest()
_bearer = HTTPBearer(auto_error=False)
def get_current_tenant(credentials, tenants: TenantStoreDep) -> TenantDefinition:
# sin header → 401 (headers={"WWW-Authenticate": "Bearer"})
# itera list_tenants() comparando hashes con hmac.compare_digest
# match con state != "active" → 403 "tenant is suspended"
# sin match → 401 "invalid API key"
TenantDep = Annotated[TenantDefinition, Depends(get_current_tenant)]
3.5 Metering — api/metering.py
(Convención hermana de api/persistence.py; log data/usage.jsonl.)
def current_period(now: datetime | None = None) -> str # "YYYY-MM" en UTC
def append_usage_event(data_dir, event) -> None # JSONL append
def read_usage_events(data_dir, tenant=None, period=None) -> list[UsageEvent]
def tokens_from_execution(execution) -> tuple[int, int]
# suma detail["tokens_in"/"tokens_out"] de los DecisionStep step=="llm_reason"
# (los pone build_node_llm_reason en runtime/nodes.py); cast int(... or 0) por mypy
def rate_invocation(plan, prior_invocations, tokens_in, tokens_out) -> tuple[float, float]
# inv_fee = 0 si prior < plan.included_invocations, si no price_per_invocation_eur
# tok_fee = in/1000*p_in + out/1000*p_out ; round(x, 6)
def build_usage_event(execution, tenant, plan, prior_invocations) -> UsageEvent
def summarize_usage(tenant, plan, period, events) -> UsageSummary
# usage_cost = Σ cost_eur ; total = monthly_fee + usage_cost
3.6 Admin — api/tenants.py
Dos routers (patrón de executions.py): router → /api/tenants,
usage_router → /api/usage.
POST /api/tenants—CreateTenantRequest{name (pattern ^[a-z0-9_]+$ — es nombre de directorio), purpose="", plan, contact_email, owner="dashboard", allowed_agents=[]}. Valida plan existe (404) y nombre no existe (409). Genera clave, guarda hash, crea v1. RespuestaTenantCreated=TenantPublic+api_keyen claro (única vez). 201.GET /api/tenants→list[TenantPublic](sin hash: name, state, plan, purpose, contact_email, allowed_agents, updated_at).GET /api/tenants/{name}→TenantPublic| 404.GET /api/usage?period=YYYY-MM(admin, default mes actual) →list[UsageSummary]de todos los tenants — la vista de ingresos.
3.7 API pública — api/public.py (prefix /v1, tags=["public"])
Todos los endpoints llevan TenantDep. Modelos de respuesta:
PricingInfo— eco de los precios del plan del tenant.CatalogItem— name, purpose, input_label, input_placeholder, output_schema, guardrail_policies (transparencia: la gobernanza se enseña, es el producto), model (agent.llm.model), version, pricing.PublicInvokeRequest{input: str}(sinversion).PublicInvokeResponse{execution: AgentExecution, usage: UsageEvent}.
Endpoints y mecánica:
| Endpoint | Mecánica |
|---|---|
GET /v1/catalog |
agentes state=="active" ∧ (allowed_agents vacío ∨ name ∈ allowed) |
POST /v1/agents/{name}/invoke |
agente permitido o 404 → plan del tenant (FileNotFoundError → 500 "tenant plan misconfigured") → events = read_usage_events(tenant, mes) → si plan.hard_limit y len(events) >= included → 429 → delega en invoke_agent(...) interno → _record_execution(..., tenant=t.name) (re-graba la entrada del índice con tenant) → build_usage_event(prior=len(events)) + append → respuesta |
GET /v1/executions |
collect_execution_summaries(...) filtrado por trace_ids propios (entradas del índice con tenant == t.name) |
GET /v1/executions/{trace_id} |
ownership o 404 → delega en get_execution(...) |
POST /v1/executions/{trace_id}/approve / reject |
ownership o 404 → delega en approve_execution/reject_execution (reusa ApproveRequest/RejectRequest) |
GET /v1/usage?period= |
UsageSummary del propio tenant (default mes actual) |
3.8 Cambios en ficheros existentes
api/executions.py:_record_execution(..., tenant: str | None = None)— si viene, añade"tenant"al dict de la entrada del índice. Llamadas existentes intactas. (Entradas antiguas sin tenant →meta.get("tenant").)main.py: montartenants.router(/api/tenants),tenants.usage_router(/api/usage),public.router(/v1). Retocardescriptionde FastAPI (mencionar la API pública).docker-compose.yml: envTENANTS_DIR=/app/tenants,PLANS_DIR=/app/plans; volúmenes./tenants:/app/tenants:rw(la API crea tenants) y./plans:/app/plans:ro..env.example:TENANTS_DIR=./tenants,PLANS_DIR=./plans.tests/integration/conftest.py: envTENANTS_DIR=$tmp/tenants(¡tmp!, los tests crean tenants),PLANS_DIR=<repo>/plans; añadirdeps.get_tenant_storeydeps.get_plan_storea loscache_clear().
4. Seeds y fixtures
Los hashes de los index.yaml existentes son informales ("feedface", "pending")
→ se pueden escribir seeds a mano sin script.
plans/free: 0 €/mes, 100 inv incluidas, todo a 0 €,hard_limit: true(sandbox).plans/builder: 49 €/mes, 1.000 inv, overage 0,02 €/inv, 0,004 €/1k in, 0,016 €/1k out.plans/scale: 499 €/mes, 15.000 inv, 0,015 €/inv, 0,0035 €/1k in, 0,014 €/1k out. (Cifras ilustrativas: ~pass-through GPT-4o + 30-40 % margen; el fee por invocación paga guardrails/HITL/auditoría.)tenants/acme_demo: plan builder, clave demo documentada en READMEfk_demo_2f7a1c9e4b8d4032a6e1(calcular sha256 al escribir el YAML:python -c "import hashlib;print(hashlib.sha256(b'fk_demo_2f7a1c9e4b8d4032a6e1').hexdigest())").tests/fixtures/plans/:builder(precios redondos para asserts, p. ej. 0,01 €/inv overage, 1 €/1k tokens, included=2) ysandbox(included_invocations: 1, hard_limit: true— abarata el test de cuota).
5. Plan de tests (criterio de éxito: make lint + make test-all verdes)
tests/unit/test_api_tenants.py— crear tenant 201 devuelvefk_...una vez; plan desconocido 404; nombre duplicado 409; listado sinapi_key_hash.tests/unit/test_api_public.py— sin header 401; clave inválida 401; catálogo OK con clave;allowed_agentsfiltra catálogo e invoke (404); invoke 200 → evento enusage.jsonl+/v1/usagen=1; aislamiento (B no ve ejecuciones de A: lista vacía y GET por trace 404); cuota dura 429 a la 2ª con plan sandbox; tenant suspendido 403 (editarstate:en el YAML de tmp a mano).tests/unit/test_metering.py— exención included/overage; cálculo tokens;summarize_usage(totales con monthly_fee); filtro por periodo;tokens_from_executiondesde decision_path.tests/integration/test_public_api_flow.py— assets reales: crear tenant (plan builder) → catálogo contieneincident_analyzer→ invoke escenario SIP →awaiting_approval+ evento con tokens>0 → approve vía/v1→completed→/v1/usagecon coste>0 →/api/usageadmin muestra la fila.- Convención fixtures unit:
patch_settingsautouse con monkeypatch.setenv +cache_clear()de TODAS las deps usadas (incluidas las dos nuevas).
6. Documentación a escribir
docs/monetizacion.md(nuevo): modelo de consumo (qué compra el cliente: salida validada por esquema + política aplicada + HITL + auditoría, no tokens); mecánica de tarificación (CDR congelado, exención included, hard limit); tabla de planes; qué se factura y qué no (approve/reject y ejecuciones bloqueadas por guardrail a la entrada facturan solo fee de invocación, sin tokens — el bloqueo ES el servicio); camino a producción: Stripe (metered billing),Decimal, rotación/suspensión de claves, rate limiting RPM, OIDC para el admin.- README: sección "API pública para empresas" con quickstart curl usando la
clave demo (
GET /v1/catalog,POST /v1/agents/incident_analyzer/invoke,GET /v1/usage) + fila(s) en la tabla de capacidades. - ARCHITECTURE.md: sección tenancy/metering + filas nuevas en la tabla de persistencia (tenants/plans YAML versionado; usage.jsonl append-only).
docs/futuro.md: Stripe, rate limiting por minuto, rotación/suspensión de claves vía API, panel UI "Consume/Billing", SDKs generados de OpenAPI, webhooks de HITL para tenants, export CSV de uso.docs/explicacion.md: toque ligero — fila en la tabla de persistencia y párrafo "la tercera cara: /v1".
7. Descartado a propósito (no re-abrir sin decisión)
- Segundo agente de ejemplo (support_triage): el MockProvider devuelve forma de
incidente; otro esquema rompería
schema_matchcon mock. No necesario para demostrar genericidad. - Panel UI de tenants/uso: el escaparate del MVP es la API +
/docs. → roadmap. - Endpoints rotate-key / suspend: la suspensión se honra en auth (estado en YAML); gestión vía API → roadmap.
- Auth del admin
/api: ya estaba en roadmap (OIDC); fuera de alcance.
8. Orden de implementación (checklist)
- Dominio:
domain/plan.py,domain/tenant.py,domain/usage.py - Stores:
registry/plan_store.py,registry/tenant_store.py, factory config.py(2 dirs) +api/deps.py(2 getters + aliases)api/auth.py+api/metering.pyapi/tenants.py(admin) +api/public.py(/v1)_record_executioncon tenant + wiringmain.py+ compose +.env.example- Seeds
plans/*,tenants/acme_demo+ fixtures de tests - Tests (4 ficheros) + conftest integración
make lintymake test-allverdes (mypy esstrict)- Docs (§6)
Para retomar: "implementa docs/plan-api-publica.md" — los pasos 1-9 no requieren más decisiones; todo lo decidible está decidido arriba.