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

16 KiB
Raw Permalink Blame History

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.pyPlanVersionMeta = 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.pyTenantVersionMeta = 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.pyFileSystemPlanStore(VersionedYamlStore[PlanDefinition]), kind="plan", solo lectura: list_plans(), get_plan(name, version=None), list_versions(name). Calcado de dataset_store.py.

registry/tenant_store.pyFileSystemTenantStore(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.pyget_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

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/tenantsCreateTenantRequest{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/tenantslist[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) >= included429delega 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 /v1completed/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.