Hasta aquí, cada petición la pagaba una persona: el dueño del proyecto, probando en su portátil. El coste era una curiosidad de la consola. En el momento en que la app tiene una URL, deja de serlo: cualquiera puede pedir traducciones, y cada una cuesta 0,00389 $.
La pregunta que abre la etapa no es técnica.
nos cuenta dinero cada vez que un usuario usa la app?
El dueño del proyectoSí. Y la respuesta larga ocupa el resto del capítulo.
Primero, el error de intentar abaratar el modelo
El camino obvio era buscar un modelo más barato, y se recorrió entero: se midió Haiku contra Sonnet, se escribió un adaptador para Gemini, se estudió montar un modelo abierto. Tres conclusiones, en orden de incomodidad.
La primera, que Sonnet ganaba el duelo 28 a 12 (p=0,017). La segunda, que además salía más barato en uso continuo —0,389 céntimos por traducción frente a 0,479— porque el núcleo de 25 frases va en caché y Haiku tiene el umbral de caché en 4.096 tokens, cuatro veces el de Sonnet: el bloque que Sonnet cachea, Haiku lo paga entero cada vez. La tercera la dijo el dueño del proyecto, y ahorró una semana:
estoy pensando que cambiar de modelo a estas alturas es un error. cómo podemos limitar el gasto usando sonnet?
El dueño del proyecto, después de aprobar el cambio a Gemini y antes de que se tocara nadaLo era. El modelo «gratuito» solo es gratuito mientras corre en tu portátil; hospedado en un servidor con GPU sale más caro que la factura de la API que pretendía sustituir, y el problema real no era el precio por traducción sino no tener ningún techo. Un modelo diez veces más barato sin techo sigue sin tener techo.
src/perico/generate/gemini.py traduce la forma de la petición de Anthropic a la de Google, con sus tests, y no se usa. No se borra: costó poco, documenta cómo se cambia de proveedor y deja la puerta abierta si algún día el volumen lo justifica. Un callejón sin salida explorado y señalizado vale más que uno sin explorar.
El techo que de verdad protege es un acantilado
La consola de Anthropic permite fijar un tope de gasto mensual, y ese es el único mecanismo que no depende de que nuestro código funcione: cuando se alcanza, la API deja de responder, haga lo que haga la app.
Lo primero que había que entender es que no hay que limitar la cuenta entera. Los workspaces tienen tope propio, y una clave pertenece a un workspace: se crea uno para el proyecto, se genera su clave, se le pone el límite, y ni este proyecto puede gastarse el presupuesto de otro ni al revés. Con dos avisos que la documentación deja caer de pasada: al workspace por defecto no se le pueden poner límites —hay que crear uno nuevo—, y el tope de la organización sigue aplicándose por encima aunque los de los workspaces sumen más.
El tope quedó en 10 € al mes, unas 2.570 traducciones.
El problema es lo que pasa al llegar a él. No se estrecha el caudal: se corta. Y no vuelve hasta las 00:00 UTC del día 1 del mes siguiente. Si un martes alguien descubre la app y la comparte, el miércoles está muerta durante cuatro semanas.
Dos errores que parecían el mismo
Antes de poder avisar de nada había que distinguir de qué se moría. Resulta que los dos topes fallan de forma distinta, y ninguno de los dos se parece a un error normal:
| Tope | Respuesta | Cómo se reconoce |
|---|---|---|
| El que pones tú | 400 invalid_request_error | el mensaje empieza por You have reached your specified API usage limits |
| El de tu nivel de cuenta | 429 rate_limit_error | error.details.error_code es enforced_spend_limit_reached |
El segundo es el traicionero: llega como un 429, igual que un límite de velocidad corriente, pero sin cabecera retry-after. Los reintentos automáticos del SDK se estrellan contra él uno detrás de otro sin que nadie se entere. Por eso se mira el error_code y no el estado.
Nuestra API trataba ambos como un fallo genérico, y la pantalla decía «La traducción no se ha completado. Vuelve a intentarlo.» — que es exactamente lo contrario de lo que había que hacer. Ahora los dos emiten spend_limit_reached, y la app dice que descansa.
Tres techos, de fuera adentro
El tope del workspace es la red de seguridad, no el plan. Delante van dos techos nuestros, cuyo único trabajo es que nunca se llegue al de detrás:
| Techo | Quién lo pone | Se recupera |
|---|---|---|
| 10 €/mes en el workspace | la consola de Anthropic | el día 1 del mes siguiente |
| 120 traducciones al día, entre todos | nuestra API | al día siguiente |
| 20 por hora y visitante | nuestra API | en menos de una hora |
La diferencia entre el primero y los otros dos no es el número: es que los nuestros se curan solos. Un día malo agota el techo del día y mañana la app vuelve a estar viva. Un día malo sin techo agota el mes.
Los dos son la misma estructura, una ventana deslizante: se guardan las marcas de tiempo de las últimas peticiones y se descartan las que ya han caducado. Un contador que se reinicia en punto habría dejado a quien llega a las 10:59 sin cupo durante un minuto y con el cupo entero al minuto siguiente; la ventana deslizante hace que el hueco aparezca justo cuando caduca la petición más antigua, lo que además permite decir cuántos segundos faltan en la cabecera Retry-After.
Si el día está lleno, el visitante no debe perder además una de sus veinte. Suena a detalle, y no lo es: cobrando primero, a quien se encuentra la app agotada se le vacía el cupo a base de rechazos, y mañana empieza sin nada. take() pregunta a las dos ventanas y solo apunta en ambas si las dos dejan pasar.
Quién es un visitante, sin pedirle que se registre
No hay cuentas de usuario, así que hay que identificar a alguien con lo que trae puesto: su dirección IP y su navegador, juntos y pasados por un hash —un contador no necesita guardar la dirección de nadie—. El navegador entra en la mezcla para que una oficina o una casa entera no cuenten como una sola persona.
Y hay una trampa que solo aparece al desplegar: detrás de un proxy, la dirección del cliente es la del proxy. Sin mirar la cabecera X-Forwarded-For, todos los visitantes del mundo serían el mismo y compartirían un único cupo de veinte a la hora. En local nunca se nota, porque en local no hay proxy. Hay un test que lo fija: una petición directa desde 203.0.113.7 y otra que llega a través de un proxy declarando esa misma dirección tienen que dar la misma clave.
Lo que estos techos no hacen
Los contadores viven en la memoria del proceso. Con dos procesos sirviendo la app, cada uno lleva los suyos y el techo real se duplica. Es una limitación consciente: compartirlos obligaría a escribir en la base de datos en cada petición para proteger una fracción de céntimo, y detrás sigue estando el tope del workspace, que no se duplica con nada.
Tampoco impiden que alguien decidido borre sus cookies, cambie de red y vuelva. Nada de esto es seguridad; es evitar que una tarde tonta se lleve el mes.
Los números están puestos, pero no medidos: se han elegido a ojo sobre un coste conocido, no sobre tráfico real. Y falta lo más gordo, que es dónde vive la app: 1,1 GB de dependencias no caben en un despliegue sin servidor, así que o hay una máquina pequeña de por medio o los embeddings se van a una API. Esa decisión abre la segunda mitad del capítulo.
Dónde vive: el veredicto que estaba mal formulado
Con el gasto acotado, quedaba la pregunta de siempre: en qué máquina corre esto. Y el punto de partida era una frase que yo mismo había escrito en el issue meses antes: «1,1 GB de dependencias no caben en un despliegue sin servidor». Cierta, y sin embargo inútil, porque describía el bulto que teníamos en ese momento como si fuera una propiedad de la aplicación.
Lo primero al mirar precios en serio fue descubrir que el cuello de botella no era el tamaño, era la memoria. El modelo de embeddings son ~470 MB de pesos y necesita alrededor de 1 GB residente para trabajar. Eso descarta de golpe todos los planes de 512 MB, incluido el de pago de 7 $ de Render, que parecía la opción obvia y no puede ni arrancar.
Con eso, la lista de sitios donde cabe la app tal cual, siempre encendida:
| Con torch dentro | $/mes |
|---|---|
| Hugging Face PRO (16 GB, pero duerme a las 48 h de inactividad) | 9,00 |
| Fly.io, 2 GB | 10,70 |
| Koyeb eco-medium | 10,71 |
| Render Standard | ~25 |
Y ahí aparece la comparación incómoda: el más barato de la lista cuesta más que el tope de 10 € al mes que acabábamos de ponerle a la API. El sitio donde vive el modelo saldría más caro que el modelo.
La Raspberry del cajón
podemos montar este server en una raspberry que tengo en un cajón y exponerla a internet?
El dueño del proyectoLa respuesta corta fue que no, y sin necesidad de encenderla: la Raspberry Pi 3 lleva 1 GB de RAM y no tiene variantes. El modelo solo ya pide eso. Por curiosidad se comprobó lo que parecía el problema probable —si existe una versión de torch compilada para Python 3.14 en ARM— y sí existe. La arquitectura nunca fue el obstáculo. Era la memoria.
Pero la pregunta, además de razonable, resultó ser la que ordenó todo lo demás. Porque al contestarla apareció esto: la Pi y Vercel son viables bajo exactamente la misma condición —quitar torch— y ninguna de las dos lo es con él.
Es decir, no había dos decisiones. Había una:
¿Calculamos los embeddings dentro del servidor, o se los pedimos a una API?
Y aquí hay que corregir algo que yo había dado por seguro. Al descartar Vercel, lo hice por su límite de tamaño, que para funciones Python es de 500 MB. El veredicto era correcto para 1,5 GB y falso como juicio sobre la aplicación: sin torch, el backend son ~60 MB. Cabe ocho veces. Comprobando la documentación, además, ninguno de los tres riesgos reales se materializaba: FastAPI es ciudadano de primera en su runtime, Python 3.14 está disponible, y —lo importante, porque nuestra API entera son eventos SSE— el streaming viene activado por defecto en las funciones Python.
El diseño de la etapa 16 se apoyaba en dos cosas que allí desaparecen: el lifespan, que abre el pool de conexiones una sola vez para que ninguna petición pague el saludo TCP, y la existencia de un proceso vivo entre peticiones. En serverless cada instancia fría lo vuelve a pagar. Lo cubre el pooler de Supabase, que ya usábamos, pero conviene saber que se pierde algo: aquello se midió y se defendió en su momento, y ahora se cambia a sabiendas.
La prueba que decidía todo
Quitar torch parecía caro por una razón concreta: cambiar de modelo de embeddings obliga a re-indexar las 3.497 frases, y con ello caducan las mediciones de la etapa 15 —el RETRIEVE_N=12 y el umbral de 0,45 se midieron contra este modelo, no contra otro cualquiera. Vectores de modelos distintos no son comparables aunque tengan las mismas dimensiones.
Salvo que no haya que cambiar de modelo. Hugging Face sirve por API el mismo paraphrase-multilingual-MiniLM-L12-v2 que teníamos instalado. La pregunta dejaba de ser económica y pasaba a ser verificable: ¿son los mismos vectores, o solo parecidos?
Se midió. Seis frases, la mitad ciclistas y la mitad deliberadamente domésticas, embebidas aquí y allí:
1 - coseno = -2,7e-08 y -4,6e-09
mayor diferencia
en un componente = ~8e-08
matriz de similitud
entre las frases = idéntica hasta el sexto decimalEso no es «muy parecido»: es ruido de coma flotante de 32 bits. Es el mismo vector. Nada que re-indexar, y la etapa 15 sigue en pie.
El endpoint devuelve el vector sin normalizar —norma 4,42 en el caso medido—, mientras que nuestro Embedder local usa normalize_embeddings=True y todo lo que viene después da por hecho que mide 1. Si el sustituto no normaliza, la búsqueda por coseno sigue funcionando, porque el coseno normaliza por su cuenta; lo que se rompe en silencio es cualquier cosa que use el producto escalar. Un fallo que no se cae, solo devuelve resultados peores. Por eso normalizar está en el mismo método, y hay un test que lo fija con un vector [3, 4] cuya respuesta correcta es [0.6, 0.8].
El precio de la mudanza son 163 ms por traducción: 204 ms de mediana llamando a Hugging Face frente a 41 ms calculándolo aquí. Sobre una petición que ya tarda segundos esperando a Claude, es ruido. Cinco de cinco llamadas respondieron entre 202 y 216 ms, sin arranques en frío.
Lo que cambió en el código
Casi nada, y eso no fue suerte. Todo torch entraba por una sola puerta: la clase Embedder, veintidós líneas, con el import dentro del constructor para que quien no embebe no pague la carga. El Translator ya recibía el embedder inyectado desde la etapa 16, porque hacía falta para poder probarlo con dobles.
Así que el cambio es una clase hermana con el mismo encode(), una función en config.py que decide cuál se construye, y una línea en pyproject.toml: sentence-transformers deja de ser una dependencia y pasa a ser un extra opcional. El servidor no lo instala; las herramientas de corpus sí, porque embeben miles de frases de una vez y ahí una llamada de red por frase sería absurdo.
Ponerla online, por fin
Con el modelo decidido, el gasto acotado y el servidor reducido a 47 MB, quedaba hacerlo. Tres piezas que desplegar —la API, la aplicación web y este mismo curso— y un solo repositorio para las tres.
La primera decisión fue esa: un proyecto de Vercel por pieza, todos desde el mismo repositorio. Es el patrón que la propia plataforma documenta, y aquí encaja por una razón que no es técnica: el curso documenta el código. Arreglar un fallo y contarlo en la etapa que toca es un solo commit. Con repositorios separados serían dos historias que cuentan lo mismo y acaban divergiendo.
El dueño del proyecto hizo la objeción correcta antes de montar nada:
pero si va todo a un mismo proyecto, cualquier cambio despliega todo, no?
El dueño del proyectoSí. Y se resuelve, pero conviene saber que hay que resolverlo.
Cuatro fallos, uno detrás de otro
Ninguno era grave. Los cuatro son el tipo de cosa que no se ve leyendo, solo desplegando.
1. El punto de entrada. El primer build murió con un mensaje claro:
"tool.vercel.entrypoint" is "perico.api.app:app"
but no matching module file was foundLa plataforma carga la aplicación desde un fichero en la raíz del proyecto, y la nuestra vive dentro de un paquete, en src/perico/. La solución son dos líneas: un app.py en la raíz que reexporta la aplicación.
2. El paquete que no se instalaba. Detrás del primero esperaba otro, invisible hasta que el primero se arreglara: la lista de dependencias de producción instalaba las dependencias pero no el proyecto. import perico habría fallado igual.
3. La versión de Python. La plataforma usa 3.12 por defecto y el proyecto exige 3.14.
No desplegando otra vez. Instalando el proyecto en un entorno virtual limpio, exactamente como lo hace el build, y comprobando que la aplicación se importa.
La primera comprobación que hice no valía nada: la hice en el entorno de desarrollo, donde el paquete ya estaba instalado desde hacía meses. Daba verde y no demostraba nada. Rehacerla en un entorno vacío enseñó los dos fallos siguientes de una sentada, y convirtió dos ciclos de despliegue fallido en una comprobación de treinta segundos.
4. Y el bueno. Escribí un .vercelignore para que el proyecto de la API no subiera el curso ni la aplicación web. Funcionó. Y de paso tumbó los otros dos despliegues:
Found .vercelignore (repository root)
Removed 188 ignored files defined in .vercelignore
/course/...
ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND
No package.json was found in "/vercel/path0/web"Ese fichero no es del proyecto: es del repositorio, y se aplica a los tres. Al excluir web/ y course/, borraba la carpeta de cada proyecto justo antes de su propio build. La aplicación web no llegó a construirse y el curso estuvo publicado un rato como un sitio vacío que devolvía 404 en todas sus rutas.
Dos dominios que parecen uno
La aplicación web y la API son proyectos distintos, así que el navegador estaría llamando a otro origen y haría falta CORS. Se evita con un rewrite: el proyecto web reenvía todo lo que empieza por /api/ al dominio de la API.
{
"rewrites": [
{ "source": "/api/:path*",
"destination": "https://…-api….vercel.app/api/:path*" }
]
}El navegador sigue viendo un solo origen, no hay CORS y no cambió ni una línea del cliente, porque ya llamaba en ruta relativa.
Quedaba una incógnita de verdad: nuestra API responde en streaming, y un proxy puede acumular la respuesta y soltarla de golpe. Se midió:
15 trozos repartidos a lo largo de 2.326 msLlega a trozos. El texto sigue apareciendo palabra a palabra.
Lo que se comprobó antes de cantar victoria
No «parece que va». Cada pieza, contra el sitio real:
| comprobación | resultado |
|---|---|
| salud de la API | {"ok": true, "phrases": 3134} — cruzó hasta Supabase |
| traducción | flujo completo, con los embeddings servidos por Hugging Face |
| caché del prompt | desde la segunda llamada, 0,0037 $ de media |
| voz y dictado | MP3 devuelto, transcripción de ida y vuelta |
| streaming tras el proxy | 15 trozos, sin acumular |
Ese dato de la caché merece una vuelta. En local medimos 0,0039 $ por traducción. En producción, 0,0037 $. Que coincidan no es casualidad ni suerte: significa que el bloque cacheado sigue siendo idéntico entre peticiones y que el servidor sin estado no rompe nada, que era exactamente el riesgo de mover esto a funciones que nacen y mueren en cada petición.
Y el último detalle, el de siempre
Tres proyectos colgando del mismo repositorio y un solo build simultáneo en el plan gratuito: cada push construía las tres cosas para rehacer, como mucho, una.
Se arregla con un comando por proyecto que decide si vale la pena construir. Con una inversión que engaña:
salir con código 1 -> construye
salir con código 0 -> cancelaAsí que git diff --quiet —que devuelve 0 cuando no hay cambios— dice «no construyas» justo cuando no hay nada nuevo. Se lee al revés la primera vez, y por eso se verificó contra un commit real antes de darlo por bueno.
- Los topes de gasto se ponen antes de abrir la puerta, no después del primer susto. Y el que de verdad protege es un acantilado, así que delante van los que se curan solos.
- Ensaya la instalación en un entorno limpio. Comprobarlo donde ya está todo instalado no demuestra nada, y cazar dos fallos a la vez cuesta treinta segundos.
- Lee dónde se aplica cada fichero de configuración.
.vercelignoreparecía del proyecto y era del repositorio; tiró dos despliegues que nadie había tocado. - Lo que solo vive en el panel de control, se pierde. Framework, instalación, build, salida y salto de build están en el repositorio, donde se leen y se revisan.
- Un proxy puede acumular lo que tú envías en trozos. Si tu aplicación transmite, mídelo después de desplegar, no antes.