cambio de enfoque para hacer una API multitenant
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user