Files
forja/docs/plan-api-publica.md

303 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.