cambio de enfoque para hacer una API multitenant

This commit is contained in:
2026-06-11 16:34:48 +02:00
parent 37633920ce
commit f67eb68e3e
2 changed files with 303 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
# 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 `/v1`** un 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:
```python
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:
```python
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`**:
```python
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 sus
`cache_clear()`.)
### 3.4 Auth — `api/auth.py`
```python
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`.)
```python
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. Respuesta `TenantCreated` = `TenantPublic` +
`api_key` en 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}` (sin `version`).
- `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`**: montar `tenants.router` (`/api/tenants`), `tenants.usage_router`
(`/api/usage`), `public.router` (`/v1`). Retocar `description` de FastAPI
(mencionar la API pública).
- **`docker-compose.yml`**: env `TENANTS_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`**: env `TENANTS_DIR=$tmp/tenants` (¡tmp!,
los tests crean tenants), `PLANS_DIR=<repo>/plans`; añadir
`deps.get_tenant_store` y `deps.get_plan_store` a los `cache_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 README
`fk_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) y `sandbox`
(`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 devuelve `fk_...` una
vez; plan desconocido 404; nombre duplicado 409; listado sin `api_key_hash`.
- **`tests/unit/test_api_public.py`** — sin header 401; clave inválida 401;
catálogo OK con clave; `allowed_agents` filtra catálogo e invoke (404);
invoke 200 → evento en `usage.jsonl` + `/v1/usage` n=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 (editar `state:` 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_execution` desde decision_path.
- **`tests/integration/test_public_api_flow.py`** — assets reales: crear tenant
(plan builder) → catálogo contiene `incident_analyzer` → invoke escenario SIP →
`awaiting_approval` + evento con tokens>0 → approve vía `/v1` → `completed` →
`/v1/usage` con coste>0 → `/api/usage` admin muestra la fila.
- Convención fixtures unit: `patch_settings` autouse 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_match` con 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)
1. [ ] Dominio: `domain/plan.py`, `domain/tenant.py`, `domain/usage.py`
2. [ ] Stores: `registry/plan_store.py`, `registry/tenant_store.py`, factory
3. [ ] `config.py` (2 dirs) + `api/deps.py` (2 getters + aliases)
4. [ ] `api/auth.py` + `api/metering.py`
5. [ ] `api/tenants.py` (admin) + `api/public.py` (/v1)
6. [ ] `_record_execution` con tenant + wiring `main.py` + compose + `.env.example`
7. [ ] Seeds `plans/*`, `tenants/acme_demo` + fixtures de tests
8. [ ] Tests (4 ficheros) + conftest integración
9. [ ] `make lint` y `make test-all` verdes (mypy es `strict`)
10. [ ] 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.
+1
View File
@@ -1 +1,2 @@
claude --resume 0bd8c3c4-89d9-4d22-958a-36ef2b8b366c claude --resume 0bd8c3c4-89d9-4d22-958a-36ef2b8b366c
claude --resume 92d2c01c-508f-4db9-8d41-c072bb562fc0