# 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_`; 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=/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.