{"name":"ai-api","description":"Proxy y balanceador de IA. Un único endpoint POST /chat con respuesta en streaming, que reparte las peticiones entre varios proveedores (round-robin) con fallback automático.","howItWorks":["Cada petición se asigna a un proveedor rotando por turnos (round-robin).","Si el proveedor del turno falla al iniciar la respuesta, se prueba el siguiente automáticamente.","La respuesta es un stream de texto plano (los trozos del modelo tal cual)."],"authentication":{"scheme":"Bearer","header":"Authorization: Bearer <AUTH_TOKEN>","appliesTo":["POST /chat"],"note":"El <AUTH_TOKEN> lo facilita el administrador de la API por un canal privado. Este endpoint de documentación NUNCA expone su valor, ni las claves de los proveedores."},"endpoints":[{"method":"GET","path":"/","auth":false,"description":"Esta documentación (HTML para navegador, JSON para agentes)."},{"method":"GET","path":"/docs","auth":false,"description":"Alias de /, siempre disponible."},{"method":"GET","path":"/health","auth":false,"description":"Estado del servicio y proveedores disponibles."},{"method":"POST","path":"/chat","auth":true,"description":"Envía una conversación y recibe la respuesta del modelo en streaming.","requestBody":{"messages":"Array no vacío de { role: \"user\" | \"assistant\" | \"system\", content: string }"},"responses":{"200":"Modo legacy (sin \"tools\"): stream de texto plano. Modo structured (con \"tools\"): stream NDJSON (ver structuredMode).","400":"JSON inválido o \"messages\"/\"tools\"/\"toolChoice\" mal formados.","401":"Falta o no coincide el token de autorización.","502":"Todos los proveedores de IA fallaron."}}],"streaming":{"contentType":"text/event-stream","note":"El cuerpo es texto plano SIN el prefijo \"data:\" del estándar SSE. NO uses EventSource ni el SDK de OpenAI: lee el ReadableStream directamente."},"structuredMode":{"summary":"Si el body incluye \"tools\", /chat cambia a modo structured y responde NDJSON (un objeto JSON por línea, separados por \"\\n\") en lugar de texto plano. El modo legacy (sin \"tools\") permanece intacto, byte a byte.","negotiation":"Sin \"tools\" → texto plano (Content-Type: text/event-stream, X-AI-Format: text). Con \"tools\" → NDJSON (Content-Type: application/x-ndjson, X-AI-Format: ndjson).","requestBody":{"messages":"Array no vacío de { role: \"user\" | \"assistant\" | \"system\", content: string }","tools":"Array opcional de ChatTool: { name: string, description?: string, parameters: JSONSchema }. Su presencia activa el modo structured.","toolChoice":"Opcional: \"auto\" | \"none\" | \"required\" | { name: string }. Con \"required\" o { name } se fuerza una tool: si el proveedor no devuelve un tool_call, se prueba el siguiente (fallback). El proxy NO valida el schema de los argumentos."},"events":{"text_delta":"{ \"type\": \"text_delta\", \"text\": \"...\" } — prosa del modelo (opcional).","tool_call":"{ \"type\": \"tool_call\", \"id\": \"call_xxx\", \"name\": \"create_recipe\", \"input\": { ... } } — \"input\" ya viene ENSAMBLADO y parseado como objeto JSON (no es un string ni fragmentos).","done":"{ \"type\": \"done\", \"finishReason\": \"tool_calls\" | \"stop\" | \"length\", \"provider\": \"Groq\", \"model\": \"...\" } — siempre incluye provider y model.","error":"{ \"type\": \"error\", \"message\": \"...\" } — error a mitad del stream."},"headers":{"X-AI-Format":"Respuesta: \"text\" (legacy) o \"ndjson\" (structured). Header aditivo.","X-AI-Prefer":"Petición (opcional): nombre de servicio a intentar primero (p.ej. \"Groq\"). Si el nombre es desconocido se ignora; el fallback al resto sigue activo."},"capabilityGating":"En modo structured solo se usan proveedores con supportsTools=true (ver /health)."},"examples":{"curl":"curl -N -X POST http://ai-api.aterrasap.es/chat \\\n  -H \"Authorization: Bearer <AUTH_TOKEN>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"messages\": [ { \"role\": \"user\", \"content\": \"Hola\" } ] }'","curlTools":"curl -N -X POST http://ai-api.aterrasap.es/chat \\\n  -H \"Authorization: Bearer <AUTH_TOKEN>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"messages\": [ { \"role\": \"user\", \"content\": \"Crea una receta de tortilla\" } ],\n    \"tools\": [ {\n      \"name\": \"create_recipe\",\n      \"description\": \"Crea una receta estructurada\",\n      \"parameters\": { \"type\": \"object\", \"properties\": { \"title\": { \"type\": \"string\" } }, \"required\": [\"title\"] }\n    } ],\n    \"toolChoice\": { \"name\": \"create_recipe\" }\n  }'\n# Respuesta NDJSON (una línea por objeto):\n# {\"type\":\"tool_call\",\"id\":\"call_x\",\"name\":\"create_recipe\",\"input\":{\"title\":\"Tortilla\"}}\n# {\"type\":\"done\",\"finishReason\":\"tool_calls\",\"provider\":\"Groq\",\"model\":\"...\"}","typescript":"const res = await fetch(\"http://ai-api.aterrasap.es/chat\", {\n  method: \"POST\",\n  headers: {\n    \"Authorization\": `Bearer ${process.env.AI_API_TOKEN}`,\n    \"Content-Type\": \"application/json\",\n  },\n  body: JSON.stringify({ messages: [{ role: \"user\", content: \"Hola\" }] }),\n});\nconst reader = res.body.getReader();\nconst decoder = new TextDecoder();\nwhile (true) {\n  const { done, value } = await reader.read();\n  if (done) break;\n  process.stdout.write(decoder.decode(value, { stream: true }));\n}"},"security":["Guarda tu <AUTH_TOKEN> en una variable de entorno, nunca en el código.","En un frontend público no pongas el token en el navegador: llama a esta API desde tu backend.","Esta API no almacena ni registra el contenido de tus mensajes más allá de reenviarlos al proveedor."]}