diff --git a/.env.example b/.env.example index 0913942..626855b 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,11 @@ CLOUD_API_WAITLIST_KEY= # is missing, /api/waitlist still succeeds but skips the confirmation email. RESEND_API_KEY= RESEND_FROM_EMAIL=no-reply@nan.builders + +# Per-key rate limits shown in the docs and served to the Discord bot. +# Not secrets: the deployed values live in wrangler.jsonc under `vars`, and +# src/lib/rateLimits.ts falls back to the same numbers when they are missing. +# Override here only to see a different value in `astro dev`. Changing them +# changes the docs contentHash, so the bot re-syncs on its next manifest poll. +RATE_LIMIT_RPM=60 +RATE_LIMIT_PARALLEL=5 diff --git a/astro.config.mjs b/astro.config.mjs index 9a6a568..415907f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; +import mdx from '@astrojs/mdx'; import preact from '@astrojs/preact'; import tailwindcss from '@tailwindcss/vite'; import rehypePrettyCode from 'rehype-pretty-code'; @@ -10,7 +11,7 @@ import rehypePrettyCode from 'rehype-pretty-code'; export default defineConfig({ output: 'server', adapter: cloudflare(), - integrations: [preact()], + integrations: [preact(), mdx()], // We do not use Astro sessions in v1 (no auth). The Cloudflare adapter // otherwise auto-enables a KV-backed session driver and tries to inject a diff --git a/package-lock.json b/package-lock.json index 4e83237..11ded6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,28 @@ { "name": "nan-website", - "version": "0.0.3", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nan-website", - "version": "0.0.3", + "version": "0.0.7", "dependencies": { "@astrojs/cloudflare": "^13.1.3", + "@astrojs/mdx": "^5.0.6", "@astrojs/preact": "^5.0.2", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "astro-i18n": "^2.2.4", "preact": "^10.29.0", "rehype-pretty-code": "^0.14.3", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "shiki": "^4.0.2", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "unified": "^11.0.5" }, "devDependencies": { "@astrojs/check": "^0.9.8", @@ -154,6 +160,83 @@ "vfile": "^6.0.3" } }, + "node_modules/@astrojs/mdx": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.6.tgz", + "integrity": "sha512-4dKe0ZMmqujofPNDHahzClkwinn9f8jHPcaXcgdGvPAlboD2mjzkUCofli2cBnxYAkdfhC6d50gBJ8i/cH8gHw==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "7.1.2", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.16.0", + "es-module-lexer": "^2.0.0", + "estree-util-visit": "^2.0.0", + "hast-util-to-html": "^9.0.5", + "piccolore": "^0.1.3", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "astro": "^6.0.0" + } + }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/internal-helpers": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.1.tgz", + "integrity": "sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==", + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.4" + } + }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/markdown-remark": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.2.tgz", + "integrity": "sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.9.1", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@astrojs/preact": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@astrojs/preact/-/preact-5.1.1.tgz", @@ -1696,6 +1779,52 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/mdx/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -2601,6 +2730,15 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -2619,6 +2757,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2867,6 +3011,27 @@ "dev": true, "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -2985,6 +3150,15 @@ "node": ">=12" } }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/astro": { "version": "6.1.5", "resolved": "https://registry.npmjs.org/astro/-/astro-6.1.5.tgz", @@ -3247,6 +3421,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -3302,6 +3486,16 @@ "node": ">=6" } }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3698,6 +3892,38 @@ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT" }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3760,6 +3986,97 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -4034,6 +4351,34 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -4057,6 +4402,33 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", @@ -4166,6 +4538,12 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -4175,31 +4553,75 @@ "url": "https://github.com/sponsors/brc-dd" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -4613,6 +5035,18 @@ "source-map-js": "^1.2.1" } }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4779,6 +5213,83 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -5044,6 +5555,108 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -5087,6 +5700,33 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-factory-space": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", @@ -5288,6 +5928,31 @@ ], "license": "MIT" }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-util-html-tag-name": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", @@ -5641,6 +6306,31 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-latin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", @@ -5869,6 +6559,73 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -5959,6 +6716,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-stringify": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", @@ -5992,6 +6764,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -6407,6 +7193,24 @@ "node": ">=8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -6731,6 +7535,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-remove-position": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", diff --git a/package.json b/package.json index 189b348..cd65f8f 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,20 @@ }, "dependencies": { "@astrojs/cloudflare": "^13.1.3", + "@astrojs/mdx": "^5.0.6", "@astrojs/preact": "^5.0.2", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "astro-i18n": "^2.2.4", "preact": "^10.29.0", "rehype-pretty-code": "^0.14.3", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "shiki": "^4.0.2", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "unified": "^11.0.5" }, "devDependencies": { "@astrojs/check": "^0.9.8", diff --git a/src/components/docs/Callout.astro b/src/components/docs/Callout.astro new file mode 100644 index 0000000..f1e32e8 --- /dev/null +++ b/src/components/docs/Callout.astro @@ -0,0 +1,60 @@ +--- +type Variant = 'info' | 'warning'; + +interface Props { + title: string; + variant?: Variant; +} + +const { title, variant = 'info' } = Astro.props; + +const styles: Record = { + info: { + border: 'border-neutral-800/60', + bg: 'bg-[#0a0a0a]', + dot: 'bg-violet-500 shadow-[0_0_8px_rgba(139,92,246,0.5)]', + }, + warning: { + border: 'border-amber-900/40', + bg: 'bg-amber-950/20', + dot: 'bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]', + }, +}; + +const s = styles[variant]; +--- + +
+
+ +
+

{title}

+
+ +
+
+
+
+ + diff --git a/src/components/docs/EndpointGrid.astro b/src/components/docs/EndpointGrid.astro new file mode 100644 index 0000000..419940b --- /dev/null +++ b/src/components/docs/EndpointGrid.astro @@ -0,0 +1,28 @@ +--- +interface Endpoint { + href: string; + title: string; + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + path: string; +} + +interface Props { + items: Endpoint[]; +} + +const { items } = Astro.props; +--- + +
+ {items.map((ep) => ( + +

{ep.title}

+

+ {ep.method} {ep.path} +

+
+ ))} +
diff --git a/src/components/docs/FieldList.astro b/src/components/docs/FieldList.astro new file mode 100644 index 0000000..4a60a65 --- /dev/null +++ b/src/components/docs/FieldList.astro @@ -0,0 +1,38 @@ +--- +interface Field { + name: string; + type: string; + description: string; +} + +interface Props { + fields: Field[]; +} + +const { fields } = Astro.props; + +const isRequired = (t: string) => /required/i.test(t); +--- + +
+
+ {fields.map((f, i) => ( +
+
+
{f.name}
+ + {f.type} + +
+
+ +
+
+ ))} +
+
diff --git a/src/components/docs/LimitationsCard.astro b/src/components/docs/LimitationsCard.astro new file mode 100644 index 0000000..f9b504b --- /dev/null +++ b/src/components/docs/LimitationsCard.astro @@ -0,0 +1,27 @@ +--- +interface Limitation { + title: string; + body: string; +} + +interface Props { + title?: string; + items: Limitation[]; +} + +const { title = 'limitaciones conocidas', items } = Astro.props; +--- + +
+

+ {title} +

+
+ {items.map((item) => ( +
+
{item.title}
+
+
+ ))} +
+
diff --git a/src/components/docs/ModelCard.astro b/src/components/docs/ModelCard.astro new file mode 100644 index 0000000..ad39560 --- /dev/null +++ b/src/components/docs/ModelCard.astro @@ -0,0 +1,53 @@ +--- +interface Spec { + label: string; + value: string; +} + +interface Props { + id: string; + name: string; + tag?: string; + leftLabel: string; + rightLabel: string; + description: string; + specs: Spec[]; + items: string[]; +} + +const { id, name, tag, leftLabel, rightLabel, description, specs, items } = Astro.props; +const heading = tag ? `${name} - ${tag}` : name; +--- + +

{heading}

+ +
+
+

+ {leftLabel} +

+

{description}

+
+ {specs.map((spec) => ( +
+
{spec.label}
+
+
+ ))} +
+
+ +
+

+ {rightLabel} +

+
    + {items.map((item) => ( +
  • + + +
  • + ))} +
+
+
diff --git a/src/components/docs/RateLimits.astro b/src/components/docs/RateLimits.astro index 99113d7..0695057 100644 --- a/src/components/docs/RateLimits.astro +++ b/src/components/docs/RateLimits.astro @@ -1,4 +1,8 @@ --- +import { env } from 'cloudflare:workers'; +import { getRateLimitsConfig } from '../../lib/rateLimits'; + +const { perKey, tokensPerMinuteByModel, requestsPerMinuteByModel } = getRateLimitsConfig(env); ---
@@ -8,11 +12,11 @@
Requests / min
-
60 rpm
+
{perKey.requestsPerMinute} rpm
Paralelo máximo
-
5 concurrentes
+
{perKey.maxParallel} concurrentes
@@ -22,22 +26,14 @@ tokens / min por modelo

-
-
deepseek-v4-flash
-
1.5M tpm
-
-
-
mimo-v2.5
-
1.5M tpm
-
-
-
qwen3.6
-
1.5M tpm
-
-
-
gemma4
-
1.5M tpm
-
+ { + tokensPerMinuteByModel.map((m) => ( +
+
{m.model}
+
{m.label}
+
+ )) + }
@@ -46,9 +42,13 @@ requests / min por modelo

-
-
rerank
-
1000 rpm
-
+ { + requestsPerMinuteByModel.map((m) => ( +
+
{m.model}
+
{m.label}
+
+ )) + }
diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..bbf43e8 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,15 @@ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; +import { z } from 'astro/zod'; + +const docs = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }), + schema: z.object({ + title: z.string(), + description: z.string(), + order: z.number().int().min(0), + locale: z.string().default('es'), + }), +}); + +export const collections = { docs }; diff --git a/src/content/docs/agents.md b/src/content/docs/agents.md new file mode 100644 index 0000000..f8db678 --- /dev/null +++ b/src/content/docs/agents.md @@ -0,0 +1,112 @@ +--- +title: Agents +description: "Despliega agentes de IA en una microVM aislada con QEMU: Hermes, terminal web, carga de ficheros y observabilidad." +order: 5 +--- + +# Agents. + +NaN Cloud te deja desplegar agentes de IA en tu propia **microVM**: una máquina virtual ligera con QEMU + KVM, su propio kernel, su propio filesystem y acceso root completo. Aislada del host y del resto de miembros. El primer tipo de agente disponible es **Hermes**. + +## Arquitectura + +Cada agente corre dentro de su propia microVM con QEMU. En lugar de compartir el kernel del host (como un container normal), arranca con su propio kernel Linux. La VM monta un disco ext4 de 20 GiB sobre un volumen en modo block, persistente. Todo lo que haces dentro —`apt install`, `pip install`, edits en `/etc`, ficheros que subas, sesiones de bash— vive en ese disco y sobrevive a reinicios. + +El shutdown es *graceful*: cuando reinicies o borres el agente, el sistema fuerza un `sync` y espera a que el journal de ext4 termine de vaciar antes de matar la VM. Sin corrupciones. + +## Hermes + +Hermes es un agente de IA conversacional que se conecta a Telegram. Puedes hablar con él, pedirle que gestione notas, ejecute comandos en su entorno, genere sitios web y mucho más. + +### 1. Crear un bot de Telegram + +Necesitas un bot de Telegram. Abre Telegram, busca [@BotFather](https://core.telegram.org/bots/tutorial#obtain-your-bot-token) y sigue las instrucciones para crear un bot nuevo. Copia el token que te da. + +### 2. Crear el agente + +Ve a [cloud.nan.builders/agents/new](https://cloud.nan.builders/agents/new) y rellena: nombre, tipo (Hermes), el token de Telegram, modelo y opcionalmente un *soul* (system prompt) que defina la personalidad de tu agente. + +![Formulario de creación de agente](/docs/agents/create-agent-form.png) + +### 3. Esperar a que esté Running + +Tras crear el agente espera ~30 segundos a que el microVM arranque, formatee el disco la primera vez (`mkfs.ext4`) y siembre el sistema de ficheros. El estado pasa a `Running` y Hermes a `Ready`. + +### 4. Hablar con tu agente + +Busca tu bot en Telegram y envíale un mensaje. Hermes responderá usando el modelo que hayas configurado. + +![Conversación con Hermes en Telegram](/docs/agents/telegram-hermes-chat.jpg) + +> **Tu agente está listo.** +> Con estos 4 pasos ya tienes a Hermes funcionando. Lo que viene a continuación son funcionalidades adicionales del panel del agente: terminal web, subida de ficheros, observabilidad, exposición HTTP, Hermes UI y gestión de variables de entorno. + +## Console — terminal web + +La pestaña **Console** abre un terminal interactivo (`bash --login`) dentro de tu microVM, sin que tengas que configurar SSH. El stream va sobre WebSocket con xterm.js: resize automático cuando ajustas el panel, status pill arriba a la derecha y botón de reconexión si la sesión se cae. + +Casos de uso típicos: + +- Instalar paquetes: `apt update && apt install -y nginx` +- Inspeccionar logs internos del agente +- Mover ficheros que hayas subido a su ubicación final +- Tirar de `htop`, `df -h`, `journalctl`, etc. + +> **Límites operativos** +> 1 sesión simultánea por agente · idle timeout 10 min · duración máxima 30 min por sesión. + +## Files — subida de ficheros + +La pestaña **Files** permite subir ficheros al microVM con drag-and-drop o picker. Multi-fichero, cola secuencial, progress bar en vivo con MiB/s. Los archivos aterrizan en `/persist/uploads/` y desde ahí los puedes mover con la Console. + +- Tamaño máximo: **200 MiB** por fichero. +- Transporte: WebSocket con chunks de 256 KiB y backpressure end-to-end. +- Filename sanitizado server-side (sin path traversal). +- Listado en vivo de lo ya subido (refresca cada 5 s). + +## Observability + +La pestaña **Observability** agrupa tres sub-pestañas: + +- **Logs** — stream en vivo de stdout/stderr del agente vía WebSocket. Buffer de las últimas 500 líneas en el cliente. +- **Events** — eventos de Kubernetes del Pod (BackOff, Scheduled, Pulled, Killing...) con tipo, razón, mensaje, edad y contador. Auto-refresh cada 15 s. +- **Metrics** — uso real de CPU, RAM y disco contra los límites configurados. CPU/RAM vía Prometheus (kubelet-cadvisor), disco vía `df` dentro del microVM (el filesystem es block-mode, kubelet no lo ve). Refresca cada 10 s. + +## Web — exposición pública + +La pestaña **Web** tiene dos sub-pestañas para sacar servicios HTTP del agente: + +### HTTP + +Cualquier servicio que tu agente sirva por HTTP (nginx, una API, un static-site) lo puedes exponer públicamente. Por ejemplo, pídele a Hermes que instale nginx con un HTML personalizado: + +![Pidiendo a Hermes que instale nginx con un HTML personalizado](/docs/agents/telegram-nginx-setup.jpg) + +En la pestaña **Web → HTTP** pulsa **Enable HTTP**. Por defecto se expone el puerto `80`; si tu servicio escucha en otro puerto, indícalo en **Container Port**. La plataforma genera una URL pública en `*.apps.nan.builders`. + +![Sitio web generado por Hermes visible desde la URL pública](/docs/agents/http-result.png) + +### Hermes UI + +Hermes incluye una UI web ligera ([nesquena/hermes-webui](https://github.com/nesquena/hermes-webui)) que se ejecuta siempre dentro del agente. Desde **Web → Hermes UI** puedes habilitar acceso externo: la plataforma genera una URL del estilo `webui--.apps.nan.builders` protegida por una contraseña per-agent que aparece en el panel. + +## Variables de entorno + +La pestaña **Env** permite añadir, editar y borrar variables de entorno del agente sin tocar el Deployment. Útil para inyectar API keys de terceros, configurar comportamiento de Hermes, etc. + +Dos variables son **protegidas** (sólo edit, no delete): `OPENAI_API_KEY` (tu key del cluster, gestionada por la plataforma) y `TELEGRAM_BOT_TOKEN`. El resto son creación / edición / borrado libre. + +## Recursos y límites + +Cada microVM se aprovisiona con: + +| Recurso | Request | Limit | +|---|---|---| +| CPU | 200m | 1 vCPU | +| RAM | 512 Mi | 2 GiB | +| Disco | — | 20 GiB (PVC block-mode) | + +CPU y RAM son los límites máximos del microVM; el uso real suele estar muy por debajo. El disco es persistente — todo lo que instales o modifiques (paquetes, archivos, configuraciones) se conserva entre reinicios. Si el disco se llena (90%+), libéralo desde la Console (`du -sh /persist/*`). + +> **Límite actual** +> Actualmente cada miembro puede desplegar **1 agente microVM**. Este límite se ampliará en futuras versiones. diff --git a/src/content/docs/api.mdx b/src/content/docs/api.mdx new file mode 100644 index 0000000..804bdd4 --- /dev/null +++ b/src/content/docs/api.mdx @@ -0,0 +1,951 @@ +--- +title: API +description: Referencia de los endpoints públicos de la API. Compatible con OpenAI. +order: 2 +--- + +import Callout from '../../components/docs/Callout.astro'; +import EndpointGrid from '../../components/docs/EndpointGrid.astro'; +import LimitationsCard from '../../components/docs/LimitationsCard.astro'; +import RateLimits from '../../components/docs/RateLimits.astro'; + +# Referencia de la API. + +Nuestra API es compatible con OpenAI: cualquier cliente o SDK que acepte un `base URL` + `API key` funciona sin cambios. La base URL es `https://api.nan.builders/v1` y la autenticación es vía `Bearer token`. Para obtener tu key, consulta [Empezar](/docs/getting-started). + + + Si usas el servicio enterprise de Helmcode recuerda que la URL de la API es api.helmcode.com. El resto de los endpoints es idéntico. + + +## Endpoints + +Listado de los endpoints disponibles. Cada uno enlaza a su sección con `request`, `response` y un ejemplo en `curl`. + + + +## Autenticación + +Todas las peticiones requieren el header `Authorization: Bearer `. La key es personal e intransferible — consulta [Empezar](/docs/getting-started) para obtener la tuya. + +```bash +curl https://api.nan.builders/v1/models \ + -H "Authorization: Bearer sk-tu-key-aqui" +``` + +

GET /v1/models

+ +Devuelve la lista de modelos disponibles para tu key. Modelos publicados: `deepseek-v4-flash`, `mimo-v2.5`, `glm5.2`, `qwen3.6`, `gemma4`, `qwen3-embedding`, `rerank`, `kokoro`, `whisper`, `flux-2-klein` (incluye el modelo de imagen `flux-2-klein`). + +### Request + +Sin body. Solo el header de autenticación. + +### Response + +```json +{ + "object": "list", + "data": [ + { + "id": "qwen3.6", + "object": "model", + "created": 1677610602, + "owned_by": "openai" + }, + { + "id": "glm5.2", + "object": "model", + "created": 1677610602, + "owned_by": "openai" + } + ] +} +``` + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/models \ + -H "Authorization: Bearer sk-tu-key-aqui" +``` + +

POST /v1/chat/completions

+ +El endpoint principal de chat. Compatible con OpenAI Chat Completions. Modelos compatibles: `deepseek-v4-flash`, `mimo-v2.5`, `glm5.2`, `qwen3.6` y `gemma4`. + +reasoning_content en el message).', + }, + { + title: 'gemma4', + body: 'Chat, streaming, vision (image input), reasoning (opt-in).', + }, + ]} +/> + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `deepseek-v4-flash`, `mimo-v2.5`, `glm5.2`, `qwen3.6` o `gemma4`. | +| `messages` | array · required | Lista de mensajes `{ role, content }`. `content` puede ser string o un array de partes `[{type:"text",text}, {type:"image_url",image_url:{url}}]` para input multimodal. | +| `max_tokens` | integer · optional | Tope de tokens generados. | +| `stream` | boolean · optional | Default `false`. Si `true`, la respuesta llega como SSE. | +| `tools` | array · optional | Function calling estándar OpenAI: `{type:"function",function:{name,description,parameters}}`. Validado solo con `qwen3.6`. | +| `tool_choice` | string \| object · optional | Controla qué tool puede invocar el modelo. Estándar OpenAI. | +| `temperature` | number · optional | Default `0.6`. | +| `top_p` | number · optional | Default `0.95`. | + +### Response + +Respuesta sin streaming. `finish_reason` puede ser `stop`, `length` o `tool_calls`. + +```json +{ + "id": "chatcmpl-...", + "created": 1778258163, + "model": "qwen3.6", + "object": "chat.completion", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "role": "assistant", + "content": "...", + "reasoning_content": "..." + } + } + ], + "usage": { + "completion_tokens": 20, + "prompt_tokens": 17, + "total_tokens": 37 + } +} +``` + +El campo `reasoning_content` se incluye solo cuando se usa `qwen3.6`. Es opcional ignorarlo. + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{"role": "user", "content": "Hola"}], + "max_tokens": 200 + }' +``` + +### Streaming + +Con `stream: true`, la respuesta se entrega como Server-Sent Events. Cada chunk es `data: {...}\n\n` con el delta en `choices[0].delta.content`. El stream termina con `data: [DONE]`. + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -N \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{"role": "user", "content": "Cuéntame un chiste corto"}], + "stream": true + }' +``` + +### Tool calling + +`qwen3.6` soporta function calling estándar OpenAI. Cuando el modelo decide invocar una tool, la respuesta incluye `choices[0].message.tool_calls` con `{id, type:"function", function:{name, arguments}}` y `finish_reason: "tool_calls"`. + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{"role": "user", "content": "¿Qué tiempo hace en Madrid?"}], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Obtiene el tiempo actual de una ciudad", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + } + ] + }' +``` + +### Vision + +`mimo-v2.5`, `qwen3.6` y `gemma4` aceptan input multimodal. El campo `content` del mensaje pasa de string a un array de partes de tipo `text` y/o `image_url`. `mimo-v2.5` también acepta `input_audio` como parte de `content`. + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "¿Qué hay en esta imagen?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/foto.jpg"}} + ] + }] + }' +``` + +### Structured outputs + +Los modelos de chat aceptan el campo `response_format` estándar de OpenAI para forzar respuestas JSON válidas. Soportamos los dos modos: + +- **json_object**: Garantiza que la respuesta sea JSON sintácticamente válido. No impone estructura. +- **json_schema**: Restringe la salida a un JSON Schema concreto. Con `strict: true` el modelo no puede emitir campos fuera del schema. + +Funciona en `qwen3.6` y `gemma4`. + +**json_object:** + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [ + {"role": "user", "content": "Devuelve un objeto user con name=Alice y age=30."} + ], + "response_format": { "type": "json_object" } + }' +``` + +**json_schema (strict):** + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [ + {"role": "user", "content": "Alice, 30 años."} + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "user", + "strict": true, + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"], + "additionalProperties": false + } + } + } + }' +``` + +Con el SDK de `openai` en Python: + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-tu-key-aqui", + base_url="https://api.nan.builders/v1" +) + +response = client.chat.completions.create( + model="qwen3.6", + messages=[{"role": "user", "content": "Alice, 30 años."}], + response_format={ + "type": "json_schema", + "json_schema": { + "name": "user", + "strict": True, + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"], + "additionalProperties": False + } + } + } +) + +import json +data = json.loads(response.choices[0].message.content) +print(data["name"], data["age"]) +``` + +### Reasoning + +Los cinco modelos generan razonamiento y lo devuelven en `choices[0].message.reasoning_content`. El mecanismo de control cambia según el modelo: + +| Modelo | Control | +|---|---| +| `qwen3.6` | `chat_template_kwargs.enable_thinking` · activo por defecto | +| `gemma4` | `chat_template_kwargs.enable_thinking` · desactivado por defecto | +| `deepseek-v4-flash` | `reasoning_effort`: `low` \| `medium` \| `high` · default `medium` | +| `mimo-v2.5` | siempre activo · no configurable por API hoy | +| `glm5.2` | emite `reasoning_content` · enfoque coding agéntico | + +#### enable_thinking (qwen3.6, gemma4) + +Toggle binario. El campo va en el body del request como `chat_template_kwargs.enable_thinking`: + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemma4", + "messages": [{"role": "user", "content": "Qué es 2+2?"}], + "chat_template_kwargs": { "enable_thinking": true } + }' +``` + +Para desactivarlo en `qwen3.6` pasa `{ "enable_thinking": false }`. + +En SDKs como `openai` de Python o Node, este campo va dentro de `extra_body`: + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-tu-key-aqui", + base_url="https://api.nan.builders/v1" +) + +response = client.chat.completions.create( + model="gemma4", + messages=[{"role": "user", "content": "Qué es 2+2?"}], + extra_body={"chat_template_kwargs": {"enable_thinking": True}} +) + +print(response.choices[0].message.reasoning_content) +``` + +#### reasoning_effort (deepseek-v4-flash) + +Parámetro estándar de OpenAI. Acepta `low`, `medium` o `high` y va como campo top-level del body — no dentro de `extra_body`. Si no lo mandas, va en `medium` por defecto. + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "deepseek-v4-flash", + "messages": [{"role": "user", "content": "Resuelve paso a paso: 3x + 7 = 22"}], + "reasoning_effort": "high" + }' +``` + +Con el SDK `openai` de Python: + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-tu-key-aqui", + base_url="https://api.nan.builders/v1" +) + +response = client.chat.completions.create( + model="deepseek-v4-flash", + messages=[{"role": "user", "content": "Resuelve paso a paso: 3x + 7 = 22"}], + reasoning_effort="high" +) + +print(response.choices[0].message.reasoning_content) +print(response.choices[0].message.content) +``` + +A más `effort`, más tokens dedicados al razonamiento y mejor calidad en problemas complejos — a cambio de latencia y consumo de tu cuota mensual. + +#### mimo-v2.5 + +MiMo V2.5 razona siempre y emite `reasoning_content` en cada respuesta. Hoy el upstream Xiaomi ignora tanto `reasoning_effort` como `enable_thinking`, así que el nivel de razonamiento no es configurable desde la API. Si necesitas controlarlo, usa `deepseek-v4-flash`. + +

POST /v1/completions

+ +Endpoint legacy de OpenAI para text completion. Modelo compatible: `qwen3.6`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `qwen3.6`. | +| `prompt` | string · required | El prompt a completar. | +| `max_tokens` | integer · optional | Tope de tokens generados. | +| `temperature` | number · optional | Default `0.6`. | +| `top_p` | number · optional | Default `0.95`. | +| `stream` | boolean · optional | Default `false`. | + +### Response + +```json +{ + "id": "cmpl-...", + "object": "text_completion", + "created": 1778258166, + "model": "qwen3.6", + "choices": [ + { + "text": "...", + "index": 0, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "completion_tokens": 10, + "prompt_tokens": 5, + "total_tokens": 15 + } +} +``` + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "prompt": "The capital of France is", + "max_tokens": 10 + }' +``` + +### Notas + +Endpoint legacy de OpenAI. Para conversaciones, usa [/v1/chat/completions](#chat-completions). + +

POST /v1/embeddings

+ +Genera embeddings vectoriales. Modelo compatible: `qwen3-embedding`. Vectores de **4096 dimensiones**. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `qwen3-embedding`. | +| `input` | string \| array · required | Texto único o array de strings a embeddear. | +| `encoding_format` | string · optional | `"float"` (default) o `"base64"`. | + +### Response + +```json +{ + "object": "list", + "model": "qwen3-embedding", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.0210, 0.0105, -0.0204, "..."] + } + ], + "usage": { + "prompt_tokens": 3, + "total_tokens": 3 + } +} +``` + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/embeddings \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3-embedding", + "input": ["Hola mundo", "Hello world"] + }' +``` + +

POST /v1/rerank

+ +Reordena una lista de documentos por relevancia a una query. Modelo compatible: `rerank` (`Qwen3-Reranker-8B`). Completa el stack RAG junto a `qwen3-embedding`: primero recuperas top-K por embeddings, después reordenas con `rerank`. Soporta 100+ idiomas, recuperación de código y búsqueda cross-lingual. Endpoint alias: `/v2/rerank`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `rerank`. | +| `query` | string · required | Consulta contra la que se mide la relevancia de cada documento. | +| `documents` | array · required | Array de strings a reordenar. La respuesta los devuelve ordenados de mayor a menor `relevance_score` con su `index` original. | +| `top_n` | integer · optional | Limita la respuesta a los `N` documentos más relevantes. Por defecto devuelve todos. | + +### Response + +```json +{ + "id": "score-a032ee5767cab0ee", + "results": [ + { + "index": 0, + "relevance_score": 0.7390941977500916, + "document": { + "text": "Paris is the capital of France." + } + }, + { + "index": 1, + "relevance_score": 0.6002889275550842, + "document": { + "text": "Berlin is the capital of Germany." + } + }, + { + "index": 2, + "relevance_score": 0.12374333292245865, + "document": { + "text": "Madrid is the capital of Spain." + } + } + ], + "meta": { + "billed_units": { + "total_tokens": 43 + }, + "tokens": { + "input_tokens": 43 + } + } +} +``` + +La respuesta incluye `id`, `results` (array de `{index, relevance_score, document}`) y `meta` con `billed_units` y conteo de tokens. `relevance_score` está en el rango [0, 1]. El `index` se refiere a la posición original del documento en el array de entrada. + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/rerank \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "rerank", + "query": "What is the capital of France?", + "documents": [ + "Paris is the capital of France and home to the Eiffel Tower.", + "Berlin is the capital of Germany.", + "Madrid is the capital of Spain." + ] + }' +``` + +Con el SDK de `openai` de Python (usando `post` directo, ya que `rerank` no forma parte del cliente OpenAI): + +```python +import os +from openai import OpenAI + +client = OpenAI( + api_key=os.environ["NAN_API_KEY"], + base_url="https://api.nan.builders/v1" +) + +response = client.post( + path="/rerank", + cast_to=object, + body={ + "model": "rerank", + "query": "What is the capital of France?", + "documents": [ + "Paris is the capital of France and home to the Eiffel Tower.", + "Berlin is the capital of Germany.", + "Madrid is the capital of Spain.", + ], + }, +) + +for r in response["results"]: + print(r["index"], r["relevance_score"]) +``` + +

POST /v1/audio/speech

+ +Sintetiza audio a partir de texto (text-to-speech). Modelo compatible: `kokoro`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `kokoro`. | +| `input` | string · required | Texto a sintetizar. | +| `voice` | string · required | Voz a usar. Algunas opciones: `af_heart` (English female), `ef_dora` (Spanish female), `em_alex` (Spanish male). [Ver listado completo](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). | +| `response_format` | string · optional | Formato del audio devuelto. Validados: `mp3` (default), `wav`, `flac`, `aac`, `pcm`, `opus`. | +| `speed` | number · optional | Default `1.0`. | + +### Response + +Archivo binario de audio en el formato pedido (sin envoltorio JSON). + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/audio/speech \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "kokoro", + "input": "Bienvenido a NaN.", + "voice": "ef_dora", + "response_format": "mp3" + }' \ + -o speech.mp3 +``` + +

POST /v1/audio/transcriptions

+ +Transcribe audio a texto (speech-to-text). Modelo compatible: `whisper`. La petición es `multipart/form-data`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `file` | file · required | Archivo de audio a transcribir. | +| `model` | string · required | `whisper`. | +| `language` | string · optional | Código ISO-639-1 (ej. `es`, `en`). Si no se pasa, se detecta automáticamente. | +| `response_format` | string · optional | Validados: `json` (default) y `verbose_json`. Otros valores funcionan pero devuelven el contenido envuelto en JSON; recomendamos solo estos dos. | +| `timestamp_granularities[]` | string · optional | Solo con `verbose_json`. Valores: `word` (timestamps por palabra) o `segment` (default). | +| `temperature` | number · optional | Sampling temperature. | + +### Response + +Ejemplo con `response_format=verbose_json`: + +```json +{ + "text": "Hola, esto es una prueba.", + "language": "es", + "task": "transcribe", + "duration": 1.728, + "segments": [ + { + "id": 1, + "start": 0.0, + "end": 1.4, + "text": " Hola, esto es una prueba.", + "tokens": [50365, 22637, "..."], + "avg_logprob": -0.059, + "compression_ratio": 0.806, + "no_speech_prob": 0.044, + "temperature": 0.0 + } + ], + "words": null +} +``` + +Si pasas `timestamp_granularities[]=word`, el campo `words` se llena con `[{word, start, end, probability}]`. + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/audio/transcriptions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -F "model=whisper" \ + -F "file=@grabacion.mp3" \ + -F "language=es" \ + -F "response_format=verbose_json" +``` + +### Limitaciones + + 2 min pueden devolver timeout 524', + body: 'Recomendamos dividir en segmentos de ≤ 2 min.', + }, + { + title: 'Formatos recomendados', + body: 'OGG/Opus y MP3 — mejor compresión, misma calidad de transcripción.', + }, + ]} +/> + +

POST /v1/responses

+ +Endpoint Responses estilo OpenAI. Modelos compatibles: `qwen3.6` y `gemma4`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `qwen3.6` o `gemma4`. | +| `input` | string \| array · required | Texto único o array de mensajes en formato OpenAI Responses. | +| `max_output_tokens` | integer · optional | Default `65536` en `qwen3.6`. | +| `temperature` | number · optional | Default `0.6`. | +| `top_p` | number · optional | Default `0.95`. | +| `instructions` | string · optional | Instrucciones de sistema. | + +### Response + +El array `output` puede contener bloques de tipo `reasoning` (solo `qwen3.6`) y `message`. + +```json +{ + "id": "resp_...", + "created_at": 1778258181, + "model": "qwen3.6", + "object": "response", + "status": "completed", + "output": [ + { + "id": "rs_...", + "type": "reasoning", + "summary": [], + "content": [ + { "type": "reasoning_text", "text": "..." } + ] + }, + { + "id": "msg_...", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { "type": "output_text", "text": "Hola.", "annotations": [] } + ] + } + ], + "usage": { + "input_tokens": 17, + "output_tokens": 118, + "total_tokens": 135 + } +} +``` + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/responses \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "input": "Hola, ¿cómo estás?" + }' +``` + +### Notas + +El streaming en este endpoint actualmente entrega un único evento `response.completed` al final, no chunks incrementales. Para streaming token-a-token usa [/v1/chat/completions](#chat-completions) con `stream: true`. + +

POST /v1/images/generations

+ +Genera imágenes a partir de texto (text-to-image). Compatible con la Images API de OpenAI. Modelo compatible: `flux-2-klein` (único modelo disponible hoy; el endpoint está diseñado para añadir más). El body es JSON. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `prompt` | string · required | Descripción textual de la imagen a generar. | +| `model` | string · optional | Default `flux-2-klein` (único modelo disponible). Un modelo desconocido devuelve `404` (`model_not_found`). | +| `n` | integer · optional | Número de imágenes a generar, entre `1` y `4`. Default `1`. Un valor mayor que `4` devuelve `400`. | +| `size` | string · optional | Formato `"ANCHOxALTO"` con ambos lados divisibles por 16, cada uno entre 256 y 1536, y aspect ratio entre 1:3 y 3:1. Valores estándar como `1024x1024`, `1536x1024` o `1024x1536` funcionan. `"auto"` u omitido → `1024x1024`. | +| `response_format` | string · optional | `"url"` (default) o `"b64_json"`. Con `url` se devuelve un enlace temporal de R2 válido ~60 minutos (mismo contrato que OpenAI). Con `b64_json` se devuelven los bytes de la imagen en base64 inline. | + + + Por compatibilidad con SDKs de OpenAI se aceptan quality, style, background, moderation, output_format, output_compression y user, pero se ignoran — Flux no actúa sobre ellos. Además, stream: true no está soportado y devuelve 400. + + +### Parámetros adicionales (extensiones NaN) + +Estos parámetros **no** forman parte de la Images API de OpenAI. Con el SDK de `openai` se pasan vía `extra_body`. + +| Campo | Tipo | Descripción | +|---|---|---| +| `seed` | integer · optional | Seed base para reproducibilidad. Cada variante (cuando `n > 1`) parte de un offset sobre este valor. | +| `guidance` | number · optional | Guidance scale de FLUX. | + +### Response + +Mismo envoltorio que la Images API de OpenAI. `created` es el timestamp Unix en segundos. + +```json +{ + "created": 1778258200, + "data": [ + { "url": "https://...r2.../image.png" } + ] +} +``` + +Con `response_format=b64_json`, cada elemento de `data` es `{ "b64_json": "..." }` en lugar de `{ "url": "..." }`. + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/images/generations \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "flux-2-klein", + "prompt": "Un faro al atardecer sobre acantilados, estilo cinemático", + "size": "1024x1024" + }' +``` + +**response_format=b64_json:** + +```bash +curl https://api.nan.builders/v1/images/generations \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "flux-2-klein", + "prompt": "Un faro al atardecer sobre acantilados", + "size": "1024x1024", + "response_format": "b64_json" + }' +``` + +Con el SDK de `openai` en Python (las extensiones `seed` y `guidance` van en `extra_body`): + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-tu-key-aqui", + base_url="https://api.nan.builders/v1" +) + +response = client.images.generate( + model="flux-2-klein", + prompt="Un faro al atardecer sobre acantilados, estilo cinemático", + size="1024x1024", + n=1, + extra_body={"seed": 42, "guidance": 3.5} +) + +print(response.data[0].url) +``` + +### Rate limits y cuota + +La generación de imágenes **no pasa por LiteLLM**, así que los límites por key de LiteLLM (ver [Rate limits](#rate-limits)) **no** le aplican. Generar imágenes no consume tu presupuesto de rpm del chat, y viceversa. Estos límites aplican igual a la API y a la consola web, y son propios de los endpoints de imágenes: + +| Límite | Valor | Descripción | +|---|---|---| +| Rate limit | `1 req/s · burst 3` | 1 request por segundo sostenido, con burst de hasta 3 (puedes disparar hasta 3 generaciones seguidas sin error). Al excederlo devuelve `429` (`rate_limit_exceeded`). | +| Cuota mensual | `100 requests / mes` | 100 requests por mes y por usuario (1 request = 1 uso, independientemente del valor de `n`). Al excederla devuelve `429` (`insufficient_quota`). Esta cuota es independiente del límite de 500M tokens/mes del chat. | +| Tier | `inference` | Requiere membresía de tier inference. Las keys de tier community reciben `403` (`tier_restricted`). | + +

POST /v1/images/edits

+ +Genera una imagen a partir de una o varias imágenes de referencia (image-to-image). Compatible con la Images API de OpenAI. Modelo compatible: `flux-2-klein`. La petición es `multipart/form-data`. Aplican la misma membresía inference-tier y la misma cuota mensual de 100 requests que [/v1/images/generations](#images-generations). + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `image` / `image[]` | file · required | Una o más imágenes de referencia (hasta 4; las extras se descartan). PNG, JPEG o WebP, cada una < 25 MB. | +| `prompt` | string · required | Descripción de la edición o transformación a aplicar. | +| `model`, `n`, `size`, `response_format` | optional | Mismo comportamiento que en [/v1/images/generations](#images-generations). Las extensiones `seed` y `guidance` también se aceptan (como campos del form). | + + + El parámetro mask no está soportado y devuelve 400 — Flux Klein no hace inpainting. + + +### Response + +Mismo envoltorio que [/v1/images/generations](#images-generations): `{ "created": ..., "data": [{ "url": "..." }] }` (o elementos `{ "b64_json": "..." }` con `response_format=b64_json`). + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/images/edits \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -F "model=flux-2-klein" \ + -F "image[]=@ref.png" \ + -F "prompt=Convierte la escena en invierno con nieve" \ + -F "size=1024x1024" +``` + +## Errores + +Los errores siguen el formato estándar de OpenAI: HTTP status no-2xx con un body JSON describiendo el problema. + +```json +{ + "error": { + "message": "...", + "type": null, + "param": null, + "code": "..." + } +} +``` + +| Código | Descripción | +|---|---| +| 400 | Parámetro inválido — el body incluye `param` con el campo que falló (p. ej. `prompt`, `n`, `size`, `stream`, `mask` o `image` en los endpoints de imágenes). El filtro de seguridad devuelve `content_policy_violation`. | +| 401 | Header `Authorization` inválido o ausente (`invalid_api_key`). | +| 403 | Tu tier no tiene acceso al endpoint (`tier_restricted`). La generación de imágenes requiere membresía inference. | +| 404 | Modelo no existe (campo `model`, `model_not_found`). | +| 429 | Rate limit excedido — `rpm_limit` o `max_parallel_requests` (`rate_limit_exceeded`), o cuota mensual agotada (`quota_exceeded` / `insufficient_quota`, como la de 100 requests de imágenes). | +| 500 | Error interno (incluye errores upstream del modelo). | +| 524 | Timeout (típico con audios grandes en [/v1/audio/transcriptions](#audio-transcriptions)). | + +## Rate limits + + diff --git a/src/content/docs/apps.md b/src/content/docs/apps.md new file mode 100644 index 0000000..bbf719f --- /dev/null +++ b/src/content/docs/apps.md @@ -0,0 +1,72 @@ +--- +title: Apps +description: Despliega tus aplicaciones desde GitHub a NaN Cloud en minutos. +order: 6 +--- + +# Apps. + +NaN Cloud te permite **desplegar tus propias apps desde un repositorio de GitHub**: construimos tu imagen, la publicamos en un entorno aislado tuyo y la servimos detrás de un dominio público con HTTPS. Todo en un clic. + +> **Antes de empezar** +> Las Apps viven dentro de un **Space**: tu propio entorno con su cuota de recursos. Si tienes la suscripción de inferencia activa, recibes **un Space Basic gratis** incluido en tu membresía. Si no, puedes comprar uno desde [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). + +## Tiers disponibles + +Cada Space pertenece a un tier. El tier define la cuota total de CPU, RAM y almacenamiento que se reparte entre todas las apps que despliegues dentro de él. Puedes **subir o bajar de tier** en cualquier momento desde el dashboard del Space (la bajada solo se permite si tu uso actual cabe en el tier nuevo). + +| Tier | CPU | RAM | Disco | Pods | Precio | +|---|---|---|---|---|---| +| Basic | 2 vCPU | 4 GiB | 20 GiB | 5 | Gratis con inferencia · $6 / €6 al mes | +| Medium | 4 vCPU | 8 GiB | 40 GiB | 10 | $12 / €12 al mes | +| Large | 4 vCPU | 16 GiB | 80 GiB | 20 | $24 / €24 al mes | + +CPU y RAM son los **topes agregados del Space** (suma de todas tus apps). Por defecto cada app que crees arranca con un límite cómodo de `500m` CPU y `500 MiB` de RAM, suficiente para una API o worker típico; puedes subir el límite por app desde la sección *Advanced options* del formulario hasta consumir el tier completo. El disco se reparte vía PVCs (5/10/20 según tier) y solo lo usan las apps que marques como *persistent*. + +## 1. Crear un Space + +Entra en [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). Si eres miembro de inferencia verás un panel que te ofrece reclamar tu Space Basic gratis: elige un *slug* (entre 1 y 20 caracteres, minúsculas, sin espacios) y pulsa **Claim free Basic**. El slug se usará para construir los dominios públicos de tus apps, así que escógelo con cariño. + +![Reclamar un Space Basic gratuito](/docs/apps/01-claim-free-space.png) + +El Space se activa al instante. + +## 2. Crear una App dentro del Space + +Abre tu Space recién creado. Verás el resumen de recursos consumidos, el botón **Change plan** por si quieres subir de tier en algún momento, y la sección **Apps in this Space**. Pulsa **New App** para arrancar el formulario. + +![Crear una nueva App dentro del Space](/docs/apps/02-space-new-app.png) + +## 3. Conectar GitHub y configurar la build + +Conecta tu cuenta de GitHub autorizando la NaN Cloud GitHub App al repositorio que vas a desplegar (la primera vez te lleva al flujo oficial de instalación en github.com). Una vez conectado, selecciona el repo de la lista, elige la rama y dale un nombre a tu App. + +> **Requisito imprescindible: Dockerfile** +> Tu repositorio **debe contener un `Dockerfile`** en la raíz (o en el path que configures). Sin Dockerfile no podemos construir tu imagen y la app no se desplegará. Así tienes control total sobre el runtime, las dependencias y los procesos que arrancan dentro de tu app. + +Si tu app es un servicio HTTP (página web, API, panel admin, etc.), marca **Expose over HTTP** e indica el **puerto** en el que tu app escucha internamente. Por ejemplo, si arrancas con `node server.js` escuchando en `:8080`, pon `8080` aquí. Nosotros nos encargamos de publicarla en un dominio público con HTTPS. + +Si tu app es un proceso que no necesita ser accesible desde fuera (un worker, un cron, un consumer de cola...), desmarca *Expose over HTTP*: la app arrancará en modo worker, sin URL pública. + +![Formulario de creación de App: GitHub + Dockerfile + puerto](/docs/apps/03-new-app-form.png) + +El bloque **Environment variables** (opcional) te deja añadir variables tanto de tiempo de ejecución como de build. Y en **Advanced options** puedes ajustar réplicas, CPU/memoria y añadir almacenamiento persistente si tu app necesita guardar estado. + +Pulsa **Deploy**. En la pantalla de detalle de la App verás la build en directo. Tras el build, si todo ha ido bien, verás que el estado pasa a `Running`. + +## 4. Abrir tu App + +Cuando el estado sea `Running`, pulsa el botón **Open** arriba a la derecha. Te abre la URL pública de tu app en una pestaña nueva. + +![App en estado Running con botón Open](/docs/apps/04-app-running.png) + +Desde la misma pantalla tienes acceso a los logs de tu app en directo, sus eventos, métricas (CPU, memoria, disco), gestión de variables de entorno y un panel de ajustes para mutar rama, Dockerfile, puerto y recursos en caliente. + +## 5. Tu app, en producción + +Y eso es todo. Tu repositorio de GitHub está sirviendo tráfico real desde un dominio público con HTTPS, sobre infra nuestra. Cada `git push` a la rama configurada (con auto-deploy activado) dispara una nueva build automáticamente. + +![Ejemplo de App desplegada y servida](/docs/apps/05-app-example.png) + +> **Tu App está viva.** +> Con estos 5 pasos ya tienes tu app desplegada. Si necesitas escalar (más recursos, más réplicas, almacenamiento persistente, más Spaces para separar entornos dev/staging/prod), puedes hacerlo en cualquier momento desde el dashboard. Apps y Spaces están en **Beta** — si encuentras algún problema, repórtalo en `#support` en Discord. diff --git a/src/pages/docs/examples.astro b/src/content/docs/examples.md similarity index 54% rename from src/pages/docs/examples.astro rename to src/content/docs/examples.md index f85e067..3ec003e 100644 --- a/src/pages/docs/examples.astro +++ b/src/content/docs/examples.md @@ -1,37 +1,34 @@ --- -import Docs from '../../layouts/Docs.astro'; -import CodeBlock from '../../components/docs/CodeBlock.astro'; +title: Ejemplos +description: Code snippets para conectar a la API de NaN con Python, Node.js, curl y más. +order: 4 --- - -
-

- // examples -

-
+# Code snippets. -

Code snippets.

+Ejemplos para conectar a la API con diferentes lenguajes y herramientas. Usa `https://api.nan.builders/v1` como base URL y tu API key personal. -

Ejemplos para conectar a la API con diferentes lenguajes y herramientas. - Usa https://api.nan.builders/v1 - como base URL y tu API key personal.

+## modelo: qwen3.6 - -

modelo: qwen3.6

-

generación de texto y chat

+generación de texto y chat -

curl

- + }' +``` -

python (openai)

- -

- Instalar: pip install openai -

+ print(content, end="", flush=True) +``` -

node.js (openai)

- -

- Instalar: npm install openai -

+} +``` + +Instalar: `npm install openai` -

opencode.json (config)

- -

- Este es el config para conectar IDEs (Cursor, OpenCode) con los 5 modelos LLM disponibles: qwen3.6, gemma4, deepseek-v4-flash, mimo-v2.5 y glm5.2. -

+} +``` + +Este es el config para conectar IDEs (Cursor, OpenCode) con los 5 modelos LLM disponibles: `qwen3.6`, `gemma4`, `deepseek-v4-flash`, `mimo-v2.5` y `glm5.2`. -

.pi/agent/models.json (config)

- -

- Config para ~/.pi/agent/models.json -

+} +``` + +Config para `~/.pi/agent/models.json` -

openclaw.json (config)

- -

- Config para ~/.openclaw/openclaw.json -

-

- maxTokens: 65536 es el máximo que soporta el modelo. params.maxTokens: 16000 es lo que se envía por request. 16K es un buen balance para la mayoría de tareas. Si necesitas respuestas más largas, súbelo — pero ten en cuenta que el reasoning también consume de ese presupuesto. -

- -

settings.json (Zed)

- settings.json (Zed) + +```json +{ "language_models": { "openai": { "api_url": "https://api.nan.builders/v1", @@ -258,28 +264,33 @@ for await (const chunk of stream) { "model": "qwen3.6" } } -}`} /> -

- Config para ~/.config/zed/settings.json — incluye inline predictions. -

- - -

modelo: qwen3-embedding

-

embeddings vectoriales

- -

curl

- +# → 4096-dimensional vectors per input +``` -

python

- +print(len(embeddings[0])) // 4096 +``` -

node.js

- d.embedding); -console.log(embeddings[0].length); // 4096`} /> +console.log(embeddings[0].length); // 4096 +``` + +## modelo: rerank - -

modelo: rerank

-

reranking semántico — completa el stack RAG

+reranking semántico — completa el stack RAG -

curl

- "Madrid is the capital of Spain." ] }' -# → results[] ordenados por relevance_score desc, con index original`} /> +# → results[] ordenados por relevance_score desc, con index original +``` + +### python -

python

- -

- Tambien funciona con requests directo o cualquier cliente HTTP — el endpoint es OpenAI-compatible en autenticacion y forma de payload. -

- - -

modelo: kokoro

-

text-to-speech

- -

curl

- +# Ver todas las voces: https://github.com/hexgrad/Kokoro-82M +``` -

python

- +) +``` -

node.js

- - - -

modelo: whisper

-

speech-to-text

- -

curl

- +curl https://api.nan.builders/v1/audio/translations \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -F "model=whisper" \ + -F "file=@grabacion.mp3" +``` + +### python -

python

- +print(translation.text) # English translation +``` -

node.js

- +console.log(result.duration); // 5.2 +``` + +## modelo: mimo-v2.5 - -

modelo: mimo-v2.5

-

omnimodal — chat, visión y audio

+omnimodal — chat, visión y audio -

curl

- -

- Con reasoning activo se recomienda max_tokens ≥ 300 para dejar margen al razonamiento. -

- -

vision (curl)

- ] }], "max_tokens": 500 - }'`} /> + }' +``` + +### python (openai) -

python (openai)

- - -
- -
-

Integración en IDEs

-
-
-

Cursor

-

Settings → OpenAI API → Base URL: https://api.nan.builders/v1, API Key: tu key

-
-
-

Zed

-

Settings → settings.json → ver config completo arriba

-
-
-

Cline / Continue / Aider

-

Configura las vars de entorno:

-
export OPENAI_BASE_URL="https://api.nan.builders/v1"
-export OPENAI_API_KEY="sk-tu-key-aqui"
-
-
-
-
+print(response.choices[0].message.content) +``` + +## Integración en IDEs + +- **Cursor**: Settings → OpenAI API → Base URL: `https://api.nan.builders/v1`, API Key: tu key +- **Zed**: Settings → `settings.json` → ver [config completo arriba](#qwen36-zed) +- **Cline / Continue / Aider**: Configura las vars de entorno: + +```bash +export OPENAI_BASE_URL="https://api.nan.builders/v1" +export OPENAI_API_KEY="sk-tu-key-aqui" +``` diff --git a/src/content/docs/getting-started.md b/src/content/docs/getting-started.md new file mode 100644 index 0000000..f08bd19 --- /dev/null +++ b/src/content/docs/getting-started.md @@ -0,0 +1,38 @@ +--- +title: Empezar +description: Configura tu IDE o herramienta favorita para conectar a los modelos de NaN. +order: 1 +--- + +# Conectarse. + +El acceso es vía LiteLLM con una API compatible con OpenAI. Funciona con cualquier herramienta que acepte un `base URL` + `API key`: Cursor, Cline, Continue, Aider, Open Code, Open WebUI o cualquier SDK compatible con OpenAI. + +## Obtener tu API Key + +Debes estar dentro de la comunidad NaN. Si ya estás suscrito, genera tu API Key desde la sección de ajustes del usuario en el apartado "API Keys" de la [plataforma](https://cloud.nan.builders/). La key es personal e intransferible. + +> **Nota** +> El soporte es solo para temas técnicos. + +## Configurar tu herramienta + +| Campo | Valor | +|---|---| +| base URL | `https://api.nan.builders/v1` | +| API Key | `sk-tu-key-aqui` | +| Model | `qwen3.6` | + +Ejemplo de configuración OpenAI-compatible: + +```json +provider: { + openai: { + npm: "@ai-sdk/openai", + name: "NaN", + apiKey: "sk-tu-key-aqui", + baseURL: "https://api.nan.builders/v1", + model: "qwen3.6" + } +} +``` diff --git a/src/content/docs/intro.md b/src/content/docs/intro.md new file mode 100644 index 0000000..d123131 --- /dev/null +++ b/src/content/docs/intro.md @@ -0,0 +1,26 @@ +--- +title: Introducción +description: Conecta tus herramientas favoritas (OpenCode, Cursor, Cline, etc) a nuestro cluster compartido de inferencia. +order: 0 +--- + +# Bienvenido a NaN. + +Esta doc explica cómo conectar tus herramientas a nuestras GPUs. El cluster corre modelos abiertos con una API compatible con OpenAI. Si algo acepta un `base URL` + `API key`, funciona con NaN. + +> **Para obtener tu API Key** +> Debes estar dentro de la comunidad NaN. Puedes generar tu API Key desde la sección de ajustes del usuario en el apartado "API Keys" de la [plataforma](https://cloud.nan.builders/). La key es personal e intransferible. + +## Rate limits + +| Métrica | Valor | +|---|---| +| Requests por minuto | 60 rpm | +| Paralelo máximo | 5 concurrentes | + +## Qué hacer a continuación + +- [Conectarse](/docs/getting-started): endpoint, auth y configuración paso a paso. +- [Modelos](/docs/models): capacidades y límites de los modelos. +- [Ejemplos](/docs/examples): snippets en Python, Node.js y curl. +- Soporte: reporta problemas por `#support` en Discord. diff --git a/src/content/docs/models.mdx b/src/content/docs/models.mdx new file mode 100644 index 0000000..92c2938 --- /dev/null +++ b/src/content/docs/models.mdx @@ -0,0 +1,253 @@ +--- +title: Modelos +description: Especificaciones técnicas, capacidades y parámetros de los modelos del cluster compartido. +order: 3 +--- + +import ModelCard from '../../components/docs/ModelCard.astro'; +import LimitationsCard from '../../components/docs/LimitationsCard.astro'; +import RateLimits from '../../components/docs/RateLimits.astro'; + +# Models del cluster. + +Los modelos de la comunidad. Todos se acceden por la misma API OpenAI-compatible +con el mismo `base URL`. + + + +max_tokens ≥ 300)', + 'Visión (image input)', + 'Audio (audio input)', + 'Contexto de 1M tokens', + 'Generación streaming (SSE)', + ]} +/> + + + + + + + + + + + +af_heart — English (female)', + 'ef_dora — Spanish (female)', + 'em_alex — Spanish (male)', + '67 voice packs en total (ver listado completo)', + ]} +/> + + + + 2 min de duración', + body: 'Whisper procesa en CPU a ~1x realtime. Para audios de más de ~2 minutos, el proxy puede devolver un error 524 (timeout) antes de que termine la transcripción. Usa formatos comprimidos como OGG/Opus y divide archivos largos en segmentos de ≤ 2 minutos para evitarlo.', + }, + { + title: 'Formatos recomendados', + body: 'OGG/Opus y MP3 — archivos más pequeños, misma calidad de transcripción. Un audio de 60 min en OGG/Opus a 48 kbps ocupa ~20 MB vs ~550 MB en WAV.', + }, + ]} +/> + +/v1/images/generations)', + 'Image-to-image con hasta 4 referencias (/v1/images/edits)', + 'Salida como URL temporal (R2, ~60 min) o base64', + 'Reproducibilidad por seed y control de guidance', + ]} +/> + + diff --git a/src/env.d.ts b/src/env.d.ts index 330d2a5..4addc10 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -10,6 +10,9 @@ declare namespace Cloudflare { RESEND_FROM_EMAIL: string; CLOUD_API_URL: string; CLOUD_API_WAITLIST_KEY: string; + // Optional: both fall back to the defaults in src/lib/rateLimits.ts. + RATE_LIMIT_RPM?: string; + RATE_LIMIT_PARALLEL?: string; } } diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro index 0bd75bf..1b527f9 100644 --- a/src/layouts/Docs.astro +++ b/src/layouts/Docs.astro @@ -1,5 +1,6 @@ --- import '../styles/global.css'; +import { getCollection } from 'astro:content'; interface Props { title: string; @@ -10,31 +11,29 @@ const { title, description = 'Documentación de NaN — Conecta a nuestros model const siteUrl = 'https://nan.builders'; +const entries = await getCollection('docs'); +entries.sort((a, b) => a.data.order - b.data.order); + interface NavItem { slug: string; label: string; } -const navItems: NavItem[] = [ - { slug: '/docs', label: 'Introducción' }, - { slug: '/docs/getting-started', label: 'Empezar' }, - { slug: '/docs/api', label: 'API' }, - { slug: '/docs/models', label: 'Models' }, - { slug: '/docs/examples', label: 'Ejemplos' }, - { slug: '/docs/agents', label: 'Agents' }, - { slug: '/docs/apps', label: 'Apps' }, -]; - -const currentPageIndex = navItems.findIndex(item => item.slug === Astro.url.pathname); +const navItems: NavItem[] = entries.map((entry) => ({ + slug: entry.id === 'intro' ? '/docs' : `/docs/${entry.id}`, + label: entry.data.title, +})); +const currentPageIndex = navItems.findIndex((item) => item.slug === Astro.url.pathname); const prevPage = currentPageIndex > 0 ? navItems[currentPageIndex - 1] : null; -const nextPage = currentPageIndex < navItems.length - 1 ? navItems[currentPageIndex + 1] : null; +const nextPage = currentPageIndex >= 0 && currentPageIndex < navItems.length - 1 + ? navItems[currentPageIndex + 1] + : null; -// Build breadcrumb from current path const pathParts = Astro.url.pathname.split('/').filter(Boolean); const breadcrumbSegments = pathParts.map((part, i) => { const path = '/' + pathParts.slice(0, i + 1).join('/'); - const item = navItems.find(n => n.slug === path); + const item = navItems.find((n) => n.slug === path); return { label: item ? item.label : part.replace(/-/g, ' '), path: path === Astro.url.pathname ? null : path, @@ -228,18 +227,18 @@ const breadcrumbSegments = pathParts.map((part, i) => { // Build TOC from h2/h3 headings const tocContainer = document.getElementById('toc'); const headings = document.querySelectorAll('.docs-content h2, .docs-content h3'); - + if (headings.length > 0 && tocContainer) { headings.forEach((heading) => { const id = heading.id || heading.textContent?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, ''); if (id) heading.id = id; - + const link = document.createElement('a'); link.href = `#${id}`; link.className = `toc-link ${heading.tagName === 'H3' ? 'h2' : ''}`; link.textContent = heading.textContent || ''; link.dataset.target = id; - + link.addEventListener('click', (e) => { e.preventDefault(); const target = document.getElementById(id); @@ -248,7 +247,7 @@ const breadcrumbSegments = pathParts.map((part, i) => { history.replaceState(null, '', `#${id}`); } }); - + tocContainer.appendChild(link); }); @@ -273,26 +272,85 @@ const breadcrumbSegments = pathParts.map((part, i) => { }); } - // Copy-to-clipboard via event delegation - document.addEventListener('click', (e) => { - const btn = (e.target as HTMLElement).closest('.copy-btn'); - if (!btn) return; - const block = btn.closest('.code-block') as HTMLElement; - const codeText = block?.dataset?.code; - if (!codeText) return; - navigator.clipboard.writeText(codeText).then(() => { - const label = btn.querySelector('.copy-label') as HTMLElement | null; - if (label) { - label.textContent = 'Copiado!'; - setTimeout(() => { label.textContent = 'Copiar'; }, 1500); - } - }).catch(() => { - const label = btn.querySelector('.copy-label') as HTMLElement | null; - if (label) { - label.textContent = 'Error'; - setTimeout(() => { label.textContent = 'Copiar'; }, 2000); + // Progressive enhancement for Markdown-generated
 blocks:
+      // wrap each in a toolbar with language label + copy button.
+      const prettifyLanguage = (raw: string | null | undefined) => {
+        const value = (raw || 'text').replace(/^language-/, '').toLowerCase();
+        const labels: Record = {
+          js: 'JavaScript',
+          ts: 'TypeScript',
+          json: 'JSON',
+          bash: 'Bash',
+          shell: 'Shell',
+          sh: 'Shell',
+          curl: 'cURL',
+          python: 'Python',
+          py: 'Python',
+          node: 'Node.js',
+          nodejs: 'Node.js',
+          'node.js': 'Node.js',
+          zed: 'Zed',
+          text: 'Text',
+        };
+        return {
+          key: value,
+          label: labels[value] || value,
+        };
+      };
+
+      const copyToast = document.getElementById('copy-toast');
+      const showCopyToast = (text: string) => {
+        if (!copyToast) return;
+        copyToast.textContent = text;
+        copyToast.classList.add('show');
+        window.setTimeout(() => copyToast.classList.remove('show'), 1500);
+      };
+
+      document.querySelectorAll('.docs-content pre').forEach((pre) => {
+        if (pre.closest('.docs-code-block') || pre.closest('.code-block')) return;
+
+        const code = pre.querySelector('code');
+        const classLanguage = code?.className.match(/language-([\w.-]+)/)?.[1];
+        const attrLanguage = pre.getAttribute('data-language');
+        const lang = prettifyLanguage(attrLanguage || classLanguage);
+
+        const wrapper = document.createElement('div');
+        wrapper.className = 'docs-code-block';
+
+        const toolbar = document.createElement('div');
+        toolbar.className = 'docs-code-toolbar';
+
+        const label = document.createElement('span');
+        label.className = 'docs-code-label';
+        label.textContent = lang.label;
+
+        const button = document.createElement('button');
+        button.type = 'button';
+        button.className = 'copy-btn';
+        button.setAttribute('aria-label', `Copiar ${lang.label}`);
+        button.innerHTML = 'Copiar';
+        button.addEventListener('click', async () => {
+          const text = pre.textContent || '';
+          try {
+            await navigator.clipboard.writeText(text);
+            const copyLabel = button.querySelector('.copy-label');
+            if (copyLabel) copyLabel.textContent = 'Copiado!';
+            showCopyToast('¡Copiado al portapapeles!');
+            window.setTimeout(() => {
+              const resetLabel = button.querySelector('.copy-label');
+              if (resetLabel) resetLabel.textContent = 'Copiar';
+            }, 1500);
+          } catch (err) {
+            console.error('copy failed', err);
+            showCopyToast('Error al copiar');
           }
         });
+
+        toolbar.appendChild(label);
+        toolbar.appendChild(button);
+        pre.parentNode?.insertBefore(wrapper, pre);
+        wrapper.appendChild(toolbar);
+        wrapper.appendChild(pre);
       });
     
   
diff --git a/src/lib/__fixtures__/callout.expected.md b/src/lib/__fixtures__/callout.expected.md
new file mode 100644
index 0000000..ddb1684
--- /dev/null
+++ b/src/lib/__fixtures__/callout.expected.md
@@ -0,0 +1,3 @@
+> \[!INFO] Heads up
+>
+> Visit [example](https://example.com) for more info on `config.json`.
\ No newline at end of file
diff --git a/src/lib/__fixtures__/callout.mdx b/src/lib/__fixtures__/callout.mdx
new file mode 100644
index 0000000..f0b062f
--- /dev/null
+++ b/src/lib/__fixtures__/callout.mdx
@@ -0,0 +1,5 @@
+import Callout from '../../components/docs/Callout.astro';
+
+
+  Visit example for more info on config.json.
+
diff --git a/src/lib/__fixtures__/composite.expected.md b/src/lib/__fixtures__/composite.expected.md
new file mode 100644
index 0000000..0a0ebed
--- /dev/null
+++ b/src/lib/__fixtures__/composite.expected.md
@@ -0,0 +1,11 @@
+# Composite
+
+Intro paragraph with `code`.
+
+> \[!INFO] Tip
+>
+> Plain prose inside the callout.
+
+## Endpoints
+
+- [X](#x) - `GET /v1/x`
\ No newline at end of file
diff --git a/src/lib/__fixtures__/composite.mdx b/src/lib/__fixtures__/composite.mdx
new file mode 100644
index 0000000..15bc71e
--- /dev/null
+++ b/src/lib/__fixtures__/composite.mdx
@@ -0,0 +1,18 @@
+import Callout from '../../components/docs/Callout.astro';
+import EndpointGrid from '../../components/docs/EndpointGrid.astro';
+
+# Composite
+
+Intro paragraph with `code`.
+
+
+  Plain prose inside the callout.
+
+
+

Endpoints

+ + diff --git a/src/lib/__fixtures__/endpointgrid.expected.md b/src/lib/__fixtures__/endpointgrid.expected.md new file mode 100644 index 0000000..42df3c3 --- /dev/null +++ b/src/lib/__fixtures__/endpointgrid.expected.md @@ -0,0 +1,2 @@ +- [Get A](#a) - `GET /v1/a` +- [Post B](#b) - `POST /v1/b` \ No newline at end of file diff --git a/src/lib/__fixtures__/endpointgrid.mdx b/src/lib/__fixtures__/endpointgrid.mdx new file mode 100644 index 0000000..d25e364 --- /dev/null +++ b/src/lib/__fixtures__/endpointgrid.mdx @@ -0,0 +1,8 @@ +import EndpointGrid from '../../components/docs/EndpointGrid.astro'; + + diff --git a/src/lib/__fixtures__/fieldlist.expected.md b/src/lib/__fixtures__/fieldlist.expected.md new file mode 100644 index 0000000..2d3827f --- /dev/null +++ b/src/lib/__fixtures__/fieldlist.expected.md @@ -0,0 +1,2 @@ +- `model` - *string · required* - The model name. +- `temperature` - *number · optional* - Sampling temperature, default `0.6`. \ No newline at end of file diff --git a/src/lib/__fixtures__/fieldlist.mdx b/src/lib/__fixtures__/fieldlist.mdx new file mode 100644 index 0000000..18862e8 --- /dev/null +++ b/src/lib/__fixtures__/fieldlist.mdx @@ -0,0 +1,8 @@ +import FieldList from '../../components/docs/FieldList.astro'; + +0.6
.' }, + ]} +/> diff --git a/src/lib/__fixtures__/limitations.expected.md b/src/lib/__fixtures__/limitations.expected.md new file mode 100644 index 0000000..16cbd66 --- /dev/null +++ b/src/lib/__fixtures__/limitations.expected.md @@ -0,0 +1,9 @@ +### known limits + +**limit a** + +body of limit a with `code` and **bold**. + +**limit b** + +body of limit b. \ No newline at end of file diff --git a/src/lib/__fixtures__/limitations.mdx b/src/lib/__fixtures__/limitations.mdx new file mode 100644 index 0000000..c28b76f --- /dev/null +++ b/src/lib/__fixtures__/limitations.mdx @@ -0,0 +1,15 @@ +import LimitationsCard from '../../components/docs/LimitationsCard.astro'; + +code and bold.', + }, + { + title: 'limit b', + body: 'body of limit b.', + }, + ]} +/> diff --git a/src/lib/__fixtures__/modelcard.expected.md b/src/lib/__fixtures__/modelcard.expected.md new file mode 100644 index 0000000..39b33e3 --- /dev/null +++ b/src/lib/__fixtures__/modelcard.expected.md @@ -0,0 +1,13 @@ +### test-model - 1B + +Test model description. + +**capabilities** + +- Param: 1B +- Context: 8K + +**use cases** + +- item 1 +- item 2 \ No newline at end of file diff --git a/src/lib/__fixtures__/modelcard.mdx b/src/lib/__fixtures__/modelcard.mdx new file mode 100644 index 0000000..1b3d0d3 --- /dev/null +++ b/src/lib/__fixtures__/modelcard.mdx @@ -0,0 +1,18 @@ +import ModelCard from '../../components/docs/ModelCard.astro'; + + diff --git a/src/lib/__fixtures__/ratelimits.expected.md b/src/lib/__fixtures__/ratelimits.expected.md new file mode 100644 index 0000000..46febe9 --- /dev/null +++ b/src/lib/__fixtures__/ratelimits.expected.md @@ -0,0 +1,15 @@ +**rate limits por API key** + +- Requests / min: 60 rpm +- Paralelo máximo: 5 concurrentes + +**tokens / min por modelo** + +- deepseek-v4-flash: 1.5M tpm +- mimo-v2.5: 1.5M tpm +- qwen3.6: 1.5M tpm +- gemma4: 1.5M tpm + +**requests / min por modelo** + +- rerank: 1000 rpm \ No newline at end of file diff --git a/src/lib/__fixtures__/ratelimits.mdx b/src/lib/__fixtures__/ratelimits.mdx new file mode 100644 index 0000000..680a3b5 --- /dev/null +++ b/src/lib/__fixtures__/ratelimits.mdx @@ -0,0 +1,3 @@ +import RateLimits from '../../components/docs/RateLimits.astro'; + + diff --git a/src/lib/__fixtures__/raw-html-heading.expected.md b/src/lib/__fixtures__/raw-html-heading.expected.md new file mode 100644 index 0000000..bb27012 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-heading.expected.md @@ -0,0 +1,9 @@ +# Top + +## Section heading + +Body text follows. + +### Subsection + +More content. \ No newline at end of file diff --git a/src/lib/__fixtures__/raw-html-heading.mdx b/src/lib/__fixtures__/raw-html-heading.mdx new file mode 100644 index 0000000..9cefae5 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-heading.mdx @@ -0,0 +1,9 @@ +# Top + +

Section heading

+ +Body text follows. + +

Subsection

+ +More content. diff --git a/src/lib/__fixtures__/raw-html-inline.expected.md b/src/lib/__fixtures__/raw-html-inline.expected.md new file mode 100644 index 0000000..e92b3e4 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-inline.expected.md @@ -0,0 +1 @@ +Visit [example](https://example.com) and check `npm install`. Use **bold** and *italic* styles. \ No newline at end of file diff --git a/src/lib/__fixtures__/raw-html-inline.mdx b/src/lib/__fixtures__/raw-html-inline.mdx new file mode 100644 index 0000000..34c85f5 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-inline.mdx @@ -0,0 +1 @@ +Visit example and check npm install. Use bold and italic styles. diff --git a/src/lib/canonicalParity.test.ts b/src/lib/canonicalParity.test.ts new file mode 100644 index 0000000..7b3b907 --- /dev/null +++ b/src/lib/canonicalParity.test.ts @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { mdxToText, normalizeCanonicalText } from './mdxToText'; + +/** + * The text we serve from /api/docs is re-canonicalised and re-hashed by the + * Discord bot before it compares against our contentHash + * (nan-discord-bot, bot/docs_client.py:265 and bot/knowledge.py:390). + * + * If our output is not already a fixed point of the bot's canonicaliser, the + * two hashes never agree and every new manifest version re-indexes documents + * that did not change. + * + * What follows is a deliberately INDEPENDENT transcription of + * bot/knowledge.py::canonicalize_doc_text. It must not import our own + * normalizeCanonicalText, or the test would only be comparing that function + * against itself. The stripping is written as a code-point scan rather than a + * regex so that a typo on our side cannot be mirrored here. + */ + +// The 29 code points for which Python's str.isspace() is True. U+FEFF is not +// among them, and U+001C..U+001F are. +const PYTHON_SPACE = new Set([ + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000, 0x2001, + 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, + 0x205f, 0x3000, +]); + +/** Python's str.strip() with no argument. */ +function pyStrip(s: string): string { + let start = 0; + let end = s.length; + while (start < end && PYTHON_SPACE.has(s.charCodeAt(start))) start += 1; + while (end > start && PYTHON_SPACE.has(s.charCodeAt(end - 1))) end -= 1; + return s.slice(start, end); +} + +// re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) +const FRONTMATTER_RE = /^---\s*\n[\s\S]*?\n---\s*\n/; + +/** bot/knowledge.py::canonicalize_doc_text */ +function canonicalizeDocText(raw: string, { stripFrontmatter }: { stripFrontmatter: boolean }): string { + let text = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (stripFrontmatter) text = text.replace(FRONTMATTER_RE, ''); + text = text.replace(/\n{3,}/g, '\n\n'); + return pyStrip(text); +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(here, '__fixtures__'); +const docsDir = path.join(here, '..', 'content', 'docs'); + +function stripFrontmatterSource(raw: string): string { + return raw.replace(/^---[\s\S]*?\n---\s*\n/, ''); +} + +const fixtures = fs.readdirSync(fixturesDir).filter((f) => f.endsWith('.mdx')); +const docs = fs.readdirSync(docsDir).filter((f) => /\.(md|mdx)$/.test(f)); + +const corpus: Array<{ label: string; body: string }> = [ + ...fixtures.map((f) => ({ + label: `fixture ${f}`, + body: stripFrontmatterSource(fs.readFileSync(path.join(fixturesDir, f), 'utf8')), + })), + ...docs.map((f) => ({ + label: `doc ${f}`, + body: stripFrontmatterSource(fs.readFileSync(path.join(docsDir, f), 'utf8')), + })), +]; + +describe('mdxToText output is a fixed point of the bot canonicaliser', () => { + for (const { label, body } of corpus) { + // The happy path documented in canonicalize_doc_text's docstring. + it(`${label}: canonicalize(mdxToText(x)) === mdxToText(x)`, async () => { + const out = await mdxToText(body); + expect(canonicalizeDocText(out, { stripFrontmatter: false })).toBe(out); + }); + + // What docs_client.py actually calls on a fetched body. + it(`${label}: stable under strip_frontmatter=True too`, async () => { + const out = await mdxToText(body); + expect(canonicalizeDocText(out, { stripFrontmatter: true })).toBe(out); + }); + + // rule: '-' in the stringifier means a thematic break serialises to `---`. + // A canonical body opening with one would let the bot's frontmatter regex + // eat everything up to the next `---`. + it(`${label}: canonical text does not open with a frontmatter-shaped fence`, async () => { + const out = await mdxToText(body); + expect(out.startsWith('---')).toBe(false); + }); + } +}); + +describe('normalizeCanonicalText matches the independent Python replica', () => { + const inputs: Array<[string, string]> = [ + ['CRLF', 'a\r\nb\r\nc'], + ['lone CR', 'a\rb'], + ['three or more newlines', 'a\n\n\n\n\nb'], + ['leading and trailing spaces', ' a\nb '], + ['tabs and newlines around', '\n\t a \t\n'], + ['U+FEFF, which Python keeps', '\uFEFFtexto\uFEFF'], + ['U+001C, which Python strips', '\u001Ctexto\u001C'], + ['U+001F, which Python strips', '\u001Ftexto\u001F'], + ['NBSP', '\u00A0texto\u00A0'], + ['next line', '\u0085texto\u0085'], + ['ideographic space', '\u3000texto\u3000'], + ['line separator', '\u2028texto\u2028'], + ['empty', ''], + ['only whitespace', ' \n\t '], + ]; + + for (const [label, input] of inputs) { + it(label, () => { + expect(normalizeCanonicalText(input)).toBe(canonicalizeDocText(input, { stripFrontmatter: false })); + }); + } + + it('keeps U+FEFF, unlike JS .trim()', () => { + expect('\uFEFFx'.trim()).toBe('x'); + expect(normalizeCanonicalText('\uFEFFx')).toBe('\uFEFFx'); + }); + + it('strips U+001C, which JS .trim() keeps', () => { + expect('\u001Cx'.trim()).toBe('\u001Cx'); + expect(normalizeCanonicalText('\u001Cx')).toBe('x'); + }); +}); diff --git a/src/lib/contentHash.test.ts b/src/lib/contentHash.test.ts new file mode 100644 index 0000000..bd818ba --- /dev/null +++ b/src/lib/contentHash.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { sha256Hex } from './contentHash'; + +describe('sha256Hex', () => { + it('hashes the empty string deterministically', async () => { + expect(await sha256Hex('')).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); + + it('hashes ASCII text deterministically', async () => { + expect(await sha256Hex('hello world')).toBe( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + ); + }); + + it('hashes UTF-8 multibyte text deterministically', async () => { + // "héllo wörld" with é=0xC3 0xA9 and ö=0xC3 0xB6 + expect(await sha256Hex('héllo wörld')).toBe( + 'a1003f7d04a4115711d0b48a2eaf1359ce565d2d2a6fd65098dfcffadeeef59f', + ); + }); +}); diff --git a/src/lib/contentHash.ts b/src/lib/contentHash.ts new file mode 100644 index 0000000..1ea7401 --- /dev/null +++ b/src/lib/contentHash.ts @@ -0,0 +1,7 @@ +export async function sha256Hex(text: string): Promise { + const buf = new TextEncoder().encode(text); + const hash = await crypto.subtle.digest('SHA-256', buf); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/src/lib/docsApi.test.ts b/src/lib/docsApi.test.ts new file mode 100644 index 0000000..7963498 --- /dev/null +++ b/src/lib/docsApi.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches } from './docsApi'; + +describe('SAFE_SLUG', () => { + it('accepts safe slugs and rejects unsafe ones', () => { + const cases: Array<[string, boolean]> = [ + ['api', true], + ['getting-started', true], + ['a', true], + ['a0', true], + ['0-bad', true], + ['a'.repeat(64), true], + ['-bad', false], + ['Bad', false], + ['', false], + ['../etc', false], + ['api/', false], + ['a'.repeat(65), false], + ]; + for (const [slug, expected] of cases) { + expect(SAFE_SLUG.test(slug), slug).toBe(expected); + } + }); +}); + +describe('DOCS_CACHE_CONTROL', () => { + it('is set to 900s for both browser and shared caches', () => { + expect(DOCS_CACHE_CONTROL).toBe('public, max-age=900, s-maxage=900'); + }); +}); + +describe('ifNoneMatchMatches', () => { + const etag = '"sha256:abc"'; + + it('returns false for missing header', () => { + expect(ifNoneMatchMatches(null, etag)).toBe(false); + }); + + it('returns true for wildcard', () => { + expect(ifNoneMatchMatches('*', etag)).toBe(true); + }); + + it('matches exact value', () => { + expect(ifNoneMatchMatches(etag, etag)).toBe(true); + }); + + it('matches against a comma-separated list', () => { + expect(ifNoneMatchMatches(`"other", ${etag}, "x"`, etag)).toBe(true); + }); + + it('ignores W/ weak prefix on header and etag', () => { + expect(ifNoneMatchMatches(`W/${etag}`, etag)).toBe(true); + expect(ifNoneMatchMatches(etag, `W/${etag}`)).toBe(true); + }); + + it('does not match unrelated tags', () => { + expect(ifNoneMatchMatches('"sha256:other"', etag)).toBe(false); + }); +}); diff --git a/src/lib/docsApi.ts b/src/lib/docsApi.ts new file mode 100644 index 0000000..7c06c51 --- /dev/null +++ b/src/lib/docsApi.ts @@ -0,0 +1,19 @@ +export const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; +export const DOCS_CACHE_CONTROL = 'public, max-age=900, s-maxage=900'; + +export function quoteEtag(value: string): string { + return `"${value}"`; +} + +export function ifNoneMatchMatches(header: string | null, etag: string): boolean { + if (!header) return false; + if (header.trim() === '*') return true; + + const normalized = etag.startsWith('W/') ? etag.slice(2) : etag; + + return header + .split(',') + .map((part) => part.trim()) + .map((part) => (part.startsWith('W/') ? part.slice(2) : part)) + .some((part) => part === normalized); +} diff --git a/src/lib/docsManifestRoute.test.ts b/src/lib/docsManifestRoute.test.ts new file mode 100644 index 0000000..a61df50 --- /dev/null +++ b/src/lib/docsManifestRoute.test.ts @@ -0,0 +1,216 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '@astrojs/markdown-remark'; +import { slug as githubSlug } from 'github-slugger'; +import { globSync } from 'tinyglobby'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SAFE_SLUG } from './docsApi'; + +// astro:content and cloudflare:workers are virtual modules that only exist +// under Astro's Vite plugin, so the route module cannot be imported without +// mocking them first. The factory references are hoisted with vi.hoisted so +// they are available when vi.mock runs before the import below. +const { getCollectionMock } = vi.hoisted(() => ({ getCollectionMock: vi.fn() })); +vi.mock('astro:content', () => ({ getCollection: getCollectionMock })); +vi.mock('cloudflare:workers', () => ({ env: {} })); + +// Import AFTER the mocks so the handler binds to them. +import { GET } from '../pages/api/docs/manifest.json'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const docsDir = path.join(here, '..', 'content', 'docs'); + +/** + * Astro's glob loader (base ./src/content/docs, pattern **\/*.{md,mdx}) derives + * an entry id from the frontmatter `slug` if present — verbatim and BEFORE + * schema parsing, so the collection schema cannot veto it (generateIdDefault in + * astro/dist/content/loaders/glob.js) — and otherwise from the file path + * relative to the base — each path segment slugified with github-slugger, + * joined with '/', and a trailing '/index' collapsed (getContentEntryIdAndSlug + * in astro/dist/content/utils.js). Both branches are mirrored here, using the + * same frontmatter parser Astro itself uses for entries + * (astro/dist/content/utils.js imports it from @astrojs/markdown-remark), so a + * nested guides/foo.md and a flat doc declaring `slug: guides/foo` in any + * YAML form each yield an id the route now rejects, and CI fails at PR time + * the moment someone adds a doc whose effective id is not slug-safe — while a + * doc that Astro itself would normalize to a safe id keeps passing. + */ +function frontmatterSlug(file: string): string | undefined { + const { frontmatter } = parseFrontmatter(fs.readFileSync(file, 'utf8')); + return frontmatter.slug ? String(frontmatter.slug) : undefined; +} + +function collectDocIds(dir: string): string[] { + // Identical file discovery to the loader: same library, same pattern, same + // options (astro/dist/content/loaders/glob.js), so dotfile handling, symlink + // following and matching semantics cannot diverge. + const files = globSync('**/*.{md,mdx}', { cwd: dir, expandDirectories: false }); + return files.map((rel) => { + const pathId = rel + .replace(/\.(md|mdx)$/, '') + .split('/') + .map((segment) => githubSlug(segment)) + .join('/') + .replace(/\/index$/, ''); + return frontmatterSlug(path.join(dir, rel)) ?? pathId; + }); +} + +const corpusIds = collectDocIds(docsDir); + +function entry(id: string, body = '# Titulo\n\nContenido de prueba.\n') { + return { + id, + body, + data: { title: `Titulo ${id}`, description: `Descripcion ${id}`, order: 0, locale: 'es' }, + }; +} + +function ctx(headers: Record = {}) { + const request = new Request('https://nan.builders/api/docs/manifest.json', { headers }); + return { request } as never; +} + +describe('docs corpus ids are slug-safe', () => { + it('finds at least one real doc to guard', () => { + expect(corpusIds.length).toBeGreaterThan(0); + }); + + it('derives ids the way the glob loader does, including frontmatter slug overrides', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + fs.writeFileSync(path.join(tmp, 'flat.md'), '---\ntitle: T\nslug: guides/foo\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'flow.md'), '---\n{ title: T, slug: guides/flow }\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'quoted.md'), '---\ntitle: T\n"slug": guides/quoted\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'crlf.md'), '---\r\ntitle: T\r\nslug: guides/crlf\r\n---\r\n\r\nBody\r\n'); + fs.writeFileSync(path.join(tmp, 'Foo Bar.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.mkdirSync(path.join(tmp, 'guides')); + fs.writeFileSync(path.join(tmp, 'guides', 'nested.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'guides', 'index.md'), '---\ntitle: T\n---\n\nBody\n'); + const ids = collectDocIds(tmp).sort(); + expect(ids).toEqual([ + 'foo-bar', + 'guides', + 'guides/crlf', + 'guides/flow', + 'guides/foo', + 'guides/nested', + 'guides/quoted', + ]); + const unsafe = ids.filter((id) => !SAFE_SLUG.test(id)); + expect(unsafe).toEqual(['guides/crlf', 'guides/flow', 'guides/foo', 'guides/nested', 'guides/quoted']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('excludes dot-prefixed files and directories like the glob loader', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + fs.writeFileSync(path.join(tmp, 'visible.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, '.hidden.md'), '---\ntitle: T\nslug: guides/hidden\n---\n\nBody\n'); + fs.mkdirSync(path.join(tmp, '.draft', 'guides'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.draft', 'guides', 'foo.md'), '---\ntitle: T\n---\n\nBody\n'); + expect(collectDocIds(tmp)).toEqual(['visible']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('follows directory symlinks like the glob loader', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + const real = path.join(tmp, 'real-docs'); + const base = path.join(tmp, 'docs'); + fs.mkdirSync(real); + fs.mkdirSync(base); + fs.writeFileSync(path.join(real, 'foo.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.symlinkSync(real, path.join(base, 'linked'), 'dir'); + const ids = collectDocIds(base); + expect(ids).toEqual(['linked/foo']); + expect(SAFE_SLUG.test(ids[0])).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('ignores slug keys that are not top-level, matching the loader', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + fs.writeFileSync( + path.join(tmp, 'inner.md'), + '---\ntitle: T\nmeta:\n slug: guides/inner\n---\n\nBody\n', + ); + expect(collectDocIds(tmp)).toEqual(['inner']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + for (const id of corpusIds) { + it(`id "${id}" matches SAFE_SLUG`, () => { + expect(SAFE_SLUG.test(id)).toBe(true); + }); + } +}); + +describe('GET /api/docs/manifest.json', () => { + beforeEach(() => { + getCollectionMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fails loud with a controlled 500 when a doc id is not slug-safe', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + getCollectionMock.mockResolvedValue([entry('intro'), entry('guides/foo')]); + + const res = await GET(ctx()); + + expect(res.status).toBe(500); + expect(await res.text()).toBe('Internal error'); + expect(errorSpy).toHaveBeenCalledWith('[api/docs] failed to build manifest.json', expect.any(Error)); + const loggedError = errorSpy.mock.calls[0][1] as Error; + expect(loggedError.message).toContain('guides/foo'); + }); + + it('returns a controlled 500 (not an unhandled rejection) when mdxToText throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + getCollectionMock.mockResolvedValue([entry('intro', 'texto\n\n{oops}\n')]); + + const res = await GET(ctx()); + + expect(res.status).toBe(500); + expect(await res.text()).toBe('Internal error'); + expect(errorSpy).toHaveBeenCalledOnce(); + const loggedError = errorSpy.mock.calls[0][1] as Error; + expect(loggedError.message).toContain('{oops}'); + }); + + it('returns 200 with a well-formed manifest for valid entries', async () => { + getCollectionMock.mockResolvedValue([ + entry('intro', '# Intro\n\nHola mundo.\n'), + entry('api', '# API\n\nContenido.\n'), + ]); + + const res = await GET(ctx()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.version).toBe('string'); + expect(body.version.startsWith('sha256:')).toBe(true); + expect(Array.isArray(body.entries)).toBe(true); + expect(body.entries.length).toBe(2); + for (const e of body.entries) { + expect(e).toHaveProperty('slug'); + expect(e).toHaveProperty('contentHash'); + expect(e).toHaveProperty('contentUrl'); + expect(e.contentHash.startsWith('sha256:')).toBe(true); + expect(e.contentUrl).toBe(`/api/docs/${e.slug}.md`); + } + }); +}); diff --git a/src/lib/docsSlugRoute.test.ts b/src/lib/docsSlugRoute.test.ts new file mode 100644 index 0000000..42894e8 --- /dev/null +++ b/src/lib/docsSlugRoute.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// astro:content and cloudflare:workers are virtual modules that only exist +// under Astro's Vite plugin, so they must be mocked before the route import. +vi.mock('cloudflare:workers', () => ({ env: {} })); +vi.mock('astro:content', () => ({ getEntry: vi.fn() })); + +import { getEntry } from 'astro:content'; +import { GET } from '../pages/api/docs/[slug].md'; + +const getEntryMock = getEntry as unknown as ReturnType; + +function ctx(slug: string | undefined, init?: { ifNoneMatch?: string }) { + const headers = new Headers(); + if (init?.ifNoneMatch) headers.set('if-none-match', init.ifNoneMatch); + const request = new Request(`https://nan.builders/api/docs/${slug ?? ''}.md`, { headers }); + return { params: { slug }, request } as never; +} + +describe('GET /api/docs/[slug].md', () => { + afterEach(() => { + vi.restoreAllMocks(); + getEntryMock.mockReset(); + }); + + it('rejects an invalid slug with 400 before touching the collection', async () => { + const resp = await GET(ctx('Not A Slug')); + expect(resp.status).toBe(400); + expect(await resp.text()).toBe('Invalid slug'); + expect(getEntryMock).not.toHaveBeenCalled(); + }); + + it('renders a valid entry body to 200 markdown', async () => { + getEntryMock.mockResolvedValue({ body: '# Hello\n\nWorld' }); + + const resp = await GET(ctx('intro')); + expect(resp.status).toBe(200); + expect(resp.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8'); + const body = await resp.text(); + expect(body).toContain('# Hello'); + expect(body).toContain('World'); + }); + + it('returns a loud 500 when mdxToText throws on a bare MDX expression', async () => { + // `{oops}` parses to an mdxFlowExpression/mdxTextExpression node, which the + // real mdxToText refuses rather than silently dropping — the genuine failure + // path, exercised without mocking mdxToText itself. + getEntryMock.mockResolvedValue({ body: 'before\n\n{oops}\n\nafter' }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const resp = await GET(ctx('breaks')); + + expect(resp.status).toBe(500); + expect(await resp.text()).toBe('Internal error'); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(String(errorSpy.mock.calls[0][0])).toContain('breaks'); + }); + + it('returns 404 when the entry is missing', async () => { + getEntryMock.mockResolvedValue(undefined); + + const resp = await GET(ctx('ghost')); + expect(resp.status).toBe(404); + expect(await resp.text()).toBe('Not found'); + }); +}); diff --git a/src/lib/mdxToText.test.ts b/src/lib/mdxToText.test.ts new file mode 100644 index 0000000..df03f75 --- /dev/null +++ b/src/lib/mdxToText.test.ts @@ -0,0 +1,156 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { mdxToText } from './mdxToText'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(here, '__fixtures__'); +const docsDir = path.join(here, '..', 'content', 'docs'); + +const KNOWN_COMPONENTS = [ + 'ModelCard', + 'LimitationsCard', + 'EndpointGrid', + 'FieldList', + 'Callout', + 'RateLimits', +]; + +const FIXTURES = [ + 'modelcard', + 'limitations', + 'endpointgrid', + 'fieldlist', + 'callout', + 'ratelimits', + 'raw-html-heading', + 'raw-html-inline', + 'composite', +] as const; + +function stripFrontmatter(raw: string): string { + return raw.replace(/^---[\s\S]*?\n---\s*\n/, ''); +} + +describe('mdxToText fixtures', () => { + for (const name of FIXTURES) { + it(`renders ${name}.mdx to the expected canonical text`, async () => { + const input = fs.readFileSync(path.join(fixturesDir, `${name}.mdx`), 'utf8'); + const expected = fs.readFileSync(path.join(fixturesDir, `${name}.expected.md`), 'utf8'); + const actual = await mdxToText(stripFrontmatter(input)); + expect(actual).toBe(expected); + }); + } +}); + +describe('mdxToText invariants on docs corpus', () => { + const files = fs + .readdirSync(docsDir) + .filter((f) => /\.(md|mdx)$/.test(f)); + + for (const file of files) { + it(`${file}: contains no MDX import/export lines and no residual HTML tags`, async () => { + const raw = fs.readFileSync(path.join(docsDir, file), 'utf8'); + const out = await mdxToText(stripFrontmatter(raw)); + const codeFenceFree = out.replace(/```[\s\S]*?```/g, ''); + expect(codeFenceFree).not.toMatch(/^import\s/m); + expect(codeFenceFree).not.toMatch(/^export\s/m); + // No residual HTML tags for the supported set. + expect(codeFenceFree).not.toMatch(/<\/?h[1-4]\b/i); + expect(codeFenceFree).not.toMatch(/<\/?a\b/i); + expect(codeFenceFree).not.toMatch(/<\/?(strong|b|em|i)\b/i); + expect(codeFenceFree).not.toMatch(/<\/?code\b/i); + }); + } + + it('every custom MDX component used in src/content/docs is mapped', () => { + const used = new Set(); + for (const file of files) { + const raw = fs.readFileSync(path.join(docsDir, file), 'utf8'); + const matches = raw.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g); + for (const m of matches) used.add(m[1]); + } + for (const name of used) { + expect(KNOWN_COMPONENTS, `Unmapped component <${name}>`).toContain(name); + } + }); +}); + +describe('mdxToText rate limits', () => { + const input = "import RateLimits from '../../components/docs/RateLimits.astro';\n\n\n"; + + it('serves the values from the injected config, not hardcoded ones', async () => { + const out = await mdxToText(input, { + perKey: { requestsPerMinute: 120, maxParallel: 8 }, + tokensPerMinuteByModel: [{ model: 'foo', label: '2M tpm' }], + requestsPerMinuteByModel: [{ model: 'bar', label: '500 rpm' }], + }); + expect(out).toContain('- Requests / min: 120 rpm'); + expect(out).toContain('- Paralelo máximo: 8 concurrentes'); + expect(out).toContain('- foo: 2M tpm'); + expect(out).toContain('- bar: 500 rpm'); + }); + + it('defaults to the same numbers renders', async () => { + const out = await mdxToText(input); + expect(out).toContain('- Requests / min: 60 rpm'); + expect(out).toContain('- Paralelo máximo: 5 concurrentes'); + }); + + it('omits the per-model blocks when they are empty', async () => { + const out = await mdxToText(input, { + perKey: { requestsPerMinute: 60, maxParallel: 5 }, + tokensPerMinuteByModel: [], + requestsPerMinuteByModel: [], + }); + expect(out).not.toContain('tokens / min por modelo'); + expect(out).not.toContain('requests / min por modelo'); + }); +}); + +describe('mdxToText error cases', () => { + it('throws for an unknown MDX component', async () => { + const input = '\n'; + await expect(mdxToText(input)).rejects.toThrow(/UnknownThing/); + }); + + it('throws for a bare MDX flow expression', async () => { + await expect(mdxToText('texto\n\n{someVar}\n')).rejects.toThrow(/Unexpected MDX expression: \{someVar\}/); + }); + + it('throws for a bare MDX text expression', async () => { + await expect(mdxToText('hola {inline} mundo\n')).rejects.toThrow(/Unexpected MDX expression: \{inline\}/); + }); + + it('ignores import/export, which is how .mdx pulls in its components', async () => { + const input = "import Foo from './Foo.astro';\n\ntexto\n"; + await expect(mdxToText(input)).resolves.toBe('texto'); + }); + + it('throws when a prop expression does not reduce to a literal', async () => { + const input = "import EndpointGrid from './x';\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/identifier "items" \(in prop "items"\)/); + }); + + it('throws for a template literal prop with interpolation', async () => { + const input = "import Callout from './x';\n\n\n\ntexto\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/template literal with interpolation/); + }); + + it('throws for a computed object key, which would blank out the real field', async () => { + const input = + "export const label = 'name';\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/computed object key/); + }); + + it('throws for a spread attribute', async () => { + const input = "import Callout from './x';\n\n\n\ntexto\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/spread attribute/); + }); + + it('keeps rendering components whose props are literals', async () => { + const input = "import EndpointGrid from './x';\n\n\n"; + await expect(mdxToText(input)).resolves.toContain('GET /v1/models'); + }); +}); diff --git a/src/lib/mdxToText.ts b/src/lib/mdxToText.ts new file mode 100644 index 0000000..828d6b6 --- /dev/null +++ b/src/lib/mdxToText.ts @@ -0,0 +1,535 @@ +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkGfm from 'remark-gfm'; +import remarkMdx from 'remark-mdx'; +import remarkStringify from 'remark-stringify'; +import { DEFAULT_RATE_LIMITS, type RateLimitsConfig } from './rateLimits'; + +const KNOWN_COMPONENTS = new Set([ + 'ModelCard', + 'LimitationsCard', + 'EndpointGrid', + 'FieldList', + 'Callout', + 'RateLimits', +]); + +const AUTHOR_HTML_BLOCK_TAGS = new Set(['h1', 'h2', 'h3', 'h4']); +const AUTHOR_HTML_INLINE_TAGS = new Set(['a', 'code', 'strong', 'b', 'em', 'i', 'br']); + +const ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + '#39': "'", + nbsp: ' ', + le: '≤', + ge: '≥', + ndash: '–', + mdash: '—', + hellip: '…', +}; + +function decodeEntities(s: string): string { + return s + .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10))) + .replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (m, n) => (ENTITIES[n] !== undefined ? ENTITIES[n] : m)); +} + +export function stripInlineHtml(input: string): string { + let s = input; + s = s.replace(//gi, '\n'); + s = s.replace(/]*)>([\s\S]*?)<\/a>/gi, (_m, a, t) => { + const hrefMatch = a.match(/href\s*=\s*"([^"]*)"/i); + const href = hrefMatch ? hrefMatch[1] : null; + const text = stripInlineHtml(t).trim(); + return href ? `[${text}](${href})` : text; + }); + s = s.replace(/]*>([\s\S]*?)<\/code>/gi, (_m, t) => `\`${stripInlineHtml(t).replace(/`/g, '')}\``); + s = s.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/(strong|b)>/gi, (_m, _t, t) => `**${stripInlineHtml(t).trim()}**`); + s = s.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/(em|i)>/gi, (_m, _t, t) => `*${stripInlineHtml(t).trim()}*`); + s = s.replace(/]*>([\s\S]*?)<\/span>/gi, (_m, t) => stripInlineHtml(t)); + s = decodeEntities(s); + return s; +} + +export function htmlNodeToMarkdown(input: string): string { + let s = input; + s = s.replace(/]*>([\s\S]*?)<\/h1>/gi, (_m, t) => `\n\n# ${stripInlineHtml(t).trim()}\n\n`); + s = s.replace(/]*>([\s\S]*?)<\/h2>/gi, (_m, t) => `\n\n## ${stripInlineHtml(t).trim()}\n\n`); + s = s.replace(/]*>([\s\S]*?)<\/h3>/gi, (_m, t) => `\n\n### ${stripInlineHtml(t).trim()}\n\n`); + s = s.replace(/]*>([\s\S]*?)<\/h4>/gi, (_m, t) => `\n\n#### ${stripInlineHtml(t).trim()}\n\n`); + return stripInlineHtml(s); +} + +/** + * Reduces a prop expression to a static value. + * + * Only literal constructs are supported: anything the canonical text extractor + * cannot evaluate throws, because the alternative is a component silently + * vanishing from the text served to API consumers while the page still renders. + */ +export function astToValue(node: unknown): unknown { + if (!node || typeof node !== 'object') { + throw new Error('Unsupported MDX expression: missing node'); + } + const n = node as { [k: string]: unknown }; + switch (n.type) { + case 'Literal': + return n.value; + case 'ArrayExpression': { + const elements = (n.elements as unknown[] | undefined) ?? []; + return elements.map((e) => astToValue(e)); + } + case 'ObjectExpression': { + const obj: Record = {}; + const props = (n.properties as unknown[] | undefined) ?? []; + for (const p of props) { + const prop = p as { + type: string; + computed?: boolean; + key: { type: string; name?: string; value?: unknown }; + value: unknown; + }; + if (prop.type !== 'Property') { + throw new Error(`Unsupported MDX expression: ${prop.type} in object literal`); + } + // `{ [label]: 'x' }` has an Identifier key too, but it names a binding + // rather than the field. Reading it as a literal would silently write + // the wrong key and blank out the one the component expects. + if (prop.computed) { + throw new Error('Unsupported MDX expression: computed object key'); + } + let key: string; + if (prop.key.type === 'Identifier') key = prop.key.name as string; + else key = String(astToValue(prop.key)); + obj[key] = astToValue(prop.value); + } + return obj; + } + case 'TemplateLiteral': { + const expressions = (n.expressions as unknown[] | undefined) ?? []; + if (expressions.length > 0) { + throw new Error('Unsupported MDX expression: template literal with interpolation'); + } + const quasis = (n.quasis as { value: { cooked: string } }[] | undefined) ?? []; + return quasis.map((q) => q.value.cooked).join(''); + } + case 'UnaryExpression': { + const op = n.operator as string; + const arg = astToValue(n.argument); + if (op === '-' && typeof arg === 'number') return -arg; + if (op === '+' && typeof arg === 'number') return arg; + if (op === '!') return !arg; + throw new Error(`Unsupported MDX expression: unary operator "${op}"`); + } + case 'Identifier': { + const name = n.name as string; + if (name === 'undefined') return undefined; + throw new Error(`Unsupported MDX expression: identifier "${name}"`); + } + default: + throw new Error(`Unsupported MDX expression: ${String(n.type)}`); + } +} + +export function getAttr(node: { attributes?: unknown[] }, name: string): unknown { + if (!Array.isArray(node.attributes)) return undefined; + for (const a of node.attributes) { + const attr = a as { + type: string; + name?: string; + value?: unknown; + }; + // A spread hides which props a component actually receives. + if (attr.type === 'mdxJsxExpressionAttribute') { + throw new Error('Unsupported MDX expression: spread attribute'); + } + if (attr.type !== 'mdxJsxAttribute') continue; + if (attr.name !== name) continue; + if (typeof attr.value === 'string') return attr.value; + if (attr.value == null) return true; + const v = attr.value as { type: string; data?: { estree?: { body?: unknown[] } } }; + if (v.type === 'mdxJsxAttributeValueExpression') { + const body = v.data?.estree?.body; + if (!Array.isArray(body) || !body[0]) { + throw new Error(`Unsupported MDX expression: empty expression (in prop "${name}")`); + } + const expr = (body[0] as { expression?: unknown }).expression; + try { + return astToValue(expr); + } catch (err) { + throw new Error(`${(err as Error).message} (in prop "${name}")`); + } + } + throw new Error(`Unsupported MDX expression: ${v.type} (in prop "${name}")`); + } + return undefined; +} + +function getName(node: { name?: string | null }): string { + return node.name || ''; +} + +function textOfChildren(node: { children?: unknown[] }): string { + if (!Array.isArray(node.children)) return ''; + return node.children + .map((c) => { + const child = c as { type: string; value?: string; children?: unknown[] }; + if (child.type === 'text') return child.value || ''; + if (Array.isArray(child.children)) return textOfChildren(child); + return ''; + }) + .join(''); +} + +const stringifier = unified() + .use(remarkGfm) + .use(remarkStringify, { + bullet: '-', + emphasis: '*', + strong: '*', + fences: true, + rule: '-', + listItemIndent: 'one', + }); + +function stringifyBlockChildren(children: unknown[]): string { + const root = { type: 'root', children: (children || []) as unknown[] } as unknown; + return (stringifier.stringify(root as never) as string).replace(/\r\n/g, '\n'); +} + +function stringifyInlineChildren(children: unknown[]): string { + const root = { + type: 'root', + children: [{ type: 'paragraph', children: (children || []) as unknown[] }], + } as unknown; + return (stringifier.stringify(root as never) as string).replace(/\r\n/g, '\n').trim(); +} + +function mdToBlockChildren(md: string): unknown[] { + const proc = unified().use(remarkParse).use(remarkGfm); + const tree = proc.parse(md) as { children: unknown[] }; + return tree.children; +} + +function mdToInlineChildren(md: string): unknown[] { + const proc = unified().use(remarkParse).use(remarkGfm); + const tree = proc.parse(md) as { children: unknown[] }; + const first = tree.children[0] as { type?: string; children?: unknown[] } | undefined; + if (!first || first.type !== 'paragraph' || !Array.isArray(first.children)) { + return [{ type: 'text', value: md }]; + } + return first.children; +} + +interface MdxNode { + type: string; + name?: string | null; + attributes?: unknown[]; + children?: unknown[]; +} + +function componentToBlockMd(node: MdxNode, rateLimits: RateLimitsConfig): string { + const name = getName(node); + switch (name) { + case 'ModelCard': + return modelCardToMd(node); + case 'LimitationsCard': + return limitationsCardToMd(node); + case 'EndpointGrid': + return endpointGridToMd(node); + case 'FieldList': + return fieldListToMd(node); + case 'Callout': + return calloutToMd(node); + case 'RateLimits': + return rateLimitsToMd(rateLimits); + default: + throw new Error(`Unknown MDX block component: <${name || '?'}>`); + } +} + +function modelCardToMd(node: MdxNode): string { + const name = String(getAttr(node, 'name') ?? ''); + const tag = getAttr(node, 'tag'); + const leftLabel = String(getAttr(node, 'leftLabel') ?? ''); + const rightLabel = String(getAttr(node, 'rightLabel') ?? ''); + const description = String(getAttr(node, 'description') ?? ''); + const specs = (getAttr(node, 'specs') as Array<{ label?: string; value?: string }> | undefined) || []; + const items = (getAttr(node, 'items') as string[] | undefined) || []; + + const heading = tag ? `### ${name} - ${tag}` : `### ${name}`; + const lines: string[] = [heading, '', description, '', `**${leftLabel}**`, '']; + for (const s of specs) { + const label = String(s.label ?? ''); + const value = stripInlineHtml(String(s.value ?? '')); + lines.push(`- ${label}: ${value}`); + } + lines.push(''); + lines.push(`**${rightLabel}**`); + lines.push(''); + for (const it of items) { + lines.push(`- ${stripInlineHtml(String(it))}`); + } + lines.push(''); + return lines.join('\n'); +} + +function limitationsCardToMd(node: MdxNode): string { + const title = String(getAttr(node, 'title') ?? 'limitaciones conocidas'); + const items = (getAttr(node, 'items') as Array<{ title?: string; body?: string }> | undefined) || []; + + const lines: string[] = [`### ${title}`, '']; + for (const it of items) { + const t = stripInlineHtml(String(it.title ?? '')); + const body = stripInlineHtml(String(it.body ?? '')); + lines.push(`**${t}**`); + lines.push(''); + lines.push(body); + lines.push(''); + } + return lines.join('\n'); +} + +function endpointGridToMd(node: MdxNode): string { + const items = + (getAttr(node, 'items') as Array<{ href?: string; title?: string; method?: string; path?: string }> | undefined) || + []; + const lines: string[] = []; + for (const it of items) { + const title = String(it.title ?? ''); + const href = String(it.href ?? ''); + const method = String(it.method ?? ''); + const path = String(it.path ?? ''); + lines.push(`- [${title}](${href}) - \`${method} ${path}\``); + } + lines.push(''); + return lines.join('\n'); +} + +function fieldListToMd(node: MdxNode): string { + const fields = + (getAttr(node, 'fields') as Array<{ name?: string; type?: string; description?: string }> | undefined) || []; + const lines: string[] = []; + for (const f of fields) { + const name = String(f.name ?? ''); + const type = String(f.type ?? ''); + const description = stripInlineHtml(String(f.description ?? '')); + lines.push(`- \`${name}\` - *${type}* - ${description}`); + } + lines.push(''); + return lines.join('\n'); +} + +function calloutToMd(node: MdxNode): string { + const title = String(getAttr(node, 'title') ?? ''); + const variant = String(getAttr(node, 'variant') ?? 'info').toUpperCase(); + const innerMd = stringifyBlockChildren(node.children || []).trim(); + if (!innerMd) { + return `> [!${variant}] ${title}\n`; + } + const quoted = innerMd + .split('\n') + .map((line) => (line.length ? `> ${line}` : '>')) + .join('\n'); + return `> [!${variant}] ${title}\n>\n${quoted}\n`; +} + +function rateLimitsToMd(config: RateLimitsConfig): string { + const lines = [ + '**rate limits por API key**', + '', + `- Requests / min: ${config.perKey.requestsPerMinute} rpm`, + `- Paralelo máximo: ${config.perKey.maxParallel} concurrentes`, + '', + ]; + if (config.tokensPerMinuteByModel.length) { + lines.push('**tokens / min por modelo**', ''); + for (const m of config.tokensPerMinuteByModel) lines.push(`- ${m.model}: ${m.label}`); + lines.push(''); + } + if (config.requestsPerMinuteByModel.length) { + lines.push('**requests / min por modelo**', ''); + for (const m of config.requestsPerMinuteByModel) lines.push(`- ${m.model}: ${m.label}`); + lines.push(''); + } + return lines.join('\n'); +} + +function htmlTagToBlockMd(node: MdxNode): string { + const name = getName(node).toLowerCase(); + if (AUTHOR_HTML_BLOCK_TAGS.has(name)) { + const level = Number(name.slice(1)); + const hashes = '#'.repeat(level); + const inner = stringifyInlineChildren(node.children || []).trim(); + return `${hashes} ${inner}`; + } + // Inline tags occurring at block level — wrap as paragraph. + return htmlTagToInlineMd(node); +} + +function htmlTagToInlineMd(node: MdxNode): string { + const name = getName(node).toLowerCase(); + const innerText = stringifyInlineChildren(node.children || []).trim(); + switch (name) { + case 'a': { + const href = String(getAttr(node, 'href') ?? ''); + return href ? `[${innerText}](${href})` : innerText; + } + case 'code': + return `\`${textOfChildren(node).replace(/`/g, '')}\``; + case 'strong': + case 'b': + return `**${innerText}**`; + case 'em': + case 'i': + return `*${innerText}*`; + case 'br': + return '\n'; + default: + throw new Error(`Unknown MDX inline component: <${name || '?'}>`); + } +} + +function isMdxJsxFlowComponent(t: string): boolean { + return t === 'mdxJsxFlowElement'; +} + +function isMdxJsxTextComponent(t: string): boolean { + return t === 'mdxJsxTextElement'; +} + +function isMdxEsm(t: string): boolean { + return t === 'mdxjsEsm'; +} + +function isMdxExpression(t: string): boolean { + return t === 'mdxFlowExpression' || t === 'mdxTextExpression'; +} + +function promoteHeadingParagraphs(root: { children?: unknown[] }): void { + if (!Array.isArray(root.children)) return; + const out: unknown[] = []; + for (const c of root.children) { + const child = c as MdxNode & { type?: string }; + if ( + child.type === 'paragraph' && + Array.isArray(child.children) && + child.children.length === 1 + ) { + const only = child.children[0] as MdxNode; + if (only.type === 'mdxJsxTextElement') { + const name = getName(only).toLowerCase(); + if (AUTHOR_HTML_BLOCK_TAGS.has(name)) { + out.push({ ...only, type: 'mdxJsxFlowElement' }); + continue; + } + } + } + out.push(c); + } + root.children = out; +} + +function transformTree(root: { children?: unknown[] }, rateLimits: RateLimitsConfig): void { + promoteHeadingParagraphs(root); + + function process(node: { type?: string; children?: unknown[] }): unknown[] { + if (!Array.isArray(node.children)) return []; + const out: unknown[] = []; + for (const c of node.children) { + const child = c as MdxNode; + // Recurse first (post-order). + if (Array.isArray(child.children)) { + const newChildren = process(child); + child.children = newChildren; + } + + // import/export never render; they are how .mdx pulls in its components. + if (isMdxEsm(child.type)) { + continue; + } + + if (isMdxExpression(child.type)) { + const raw = ((child as unknown as { value?: string }).value ?? '').trim(); + throw new Error(`Unexpected MDX expression: {${raw}}`); + } + + if (child.type === 'html') { + const md = htmlNodeToMarkdown((child as unknown as { value: string }).value); + out.push(...mdToBlockChildren(md)); + continue; + } + + if (isMdxJsxFlowComponent(child.type)) { + const name = getName(child); + if (KNOWN_COMPONENTS.has(name)) { + const md = componentToBlockMd(child, rateLimits); + out.push(...mdToBlockChildren(md)); + } else if (AUTHOR_HTML_BLOCK_TAGS.has(name.toLowerCase()) || AUTHOR_HTML_INLINE_TAGS.has(name.toLowerCase())) { + const md = htmlTagToBlockMd(child); + out.push(...mdToBlockChildren(md)); + } else { + throw new Error(`Unknown MDX block component: <${name || '?'}>`); + } + continue; + } + + if (isMdxJsxTextComponent(child.type)) { + const name = getName(child); + if (AUTHOR_HTML_INLINE_TAGS.has(name.toLowerCase())) { + const md = htmlTagToInlineMd(child); + out.push(...mdToInlineChildren(md)); + } else if (KNOWN_COMPONENTS.has(name)) { + throw new Error(`MDX component <${name}> is not allowed in inline context`); + } else { + throw new Error(`Unknown MDX inline component: <${name || '?'}>`); + } + continue; + } + + out.push(child); + } + return out; + } + + root.children = process(root as { type?: string; children?: unknown[] }); +} + +/** + * The 29 code points Python's str.isspace() accepts. + * + * JS .trim() is not the same set: it strips U+FEFF, which Python keeps, and + * keeps U+001C..U+001F, which Python strips. The Discord bot re-canonicalises + * the body we serve and hashes it (nan-discord-bot, bot/docs_client.py), so a + * mismatch here would make its hash disagree with our contentHash forever. + */ +const PYTHON_WHITESPACE = + '\\t\\n\\v\\f\\r\\x1c-\\x1f \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'; +const PYTHON_STRIP_RE = new RegExp(`^[${PYTHON_WHITESPACE}]+|[${PYTHON_WHITESPACE}]+$`, 'g'); + +/** Mirrors Python's str.strip() rather than JS's .trim(). */ +export function pythonStrip(s: string): string { + return s.replace(PYTHON_STRIP_RE, ''); +} + +export function normalizeCanonicalText(text: string): string { + let s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + s = s.replace(/\n{3,}/g, '\n\n'); + return pythonStrip(s); +} + +export async function mdxToText( + body: string, + rateLimits: RateLimitsConfig = DEFAULT_RATE_LIMITS, +): Promise { + const parser = unified().use(remarkParse).use(remarkGfm).use(remarkMdx); + const tree = parser.parse(body) as { children: unknown[] }; + transformTree(tree as { children?: unknown[] }, rateLimits); + const out = stringifier.stringify(tree as never) as string; + return normalizeCanonicalText(out); +} diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts new file mode 100644 index 0000000..de6190a --- /dev/null +++ b/src/lib/rateLimits.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_RATE_LIMITS, getRateLimitsConfig } from './rateLimits'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getRateLimitsConfig', () => { + it('falls back to the defaults when the vars are absent', () => { + expect(getRateLimitsConfig({})).toEqual(DEFAULT_RATE_LIMITS); + }); + + it('reads both per-key limits from the env', () => { + const config = getRateLimitsConfig({ + RATE_LIMIT_RPM: '120', + RATE_LIMIT_PARALLEL: '8', + }); + expect(config.perKey).toEqual({ requestsPerMinute: 120, maxParallel: 8 }); + }); + + it('leaves the per-model tables untouched', () => { + const config = getRateLimitsConfig({ RATE_LIMIT_RPM: '120' }); + expect(config.tokensPerMinuteByModel).toEqual(DEFAULT_RATE_LIMITS.tokensPerMinuteByModel); + expect(config.requestsPerMinuteByModel).toEqual(DEFAULT_RATE_LIMITS.requestsPerMinuteByModel); + }); + + it.each(['0', '-1', 'abc', '1.5', '60rpm'])( + 'ignores the invalid value %s and warns instead of taking the docs down', + (raw) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = getRateLimitsConfig({ RATE_LIMIT_RPM: raw }); + expect(config.perKey.requestsPerMinute).toBe(DEFAULT_RATE_LIMITS.perKey.requestsPerMinute); + expect(warn).toHaveBeenCalledOnce(); + }, + ); + + it('treats an empty string as absent, without warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = getRateLimitsConfig({ RATE_LIMIT_PARALLEL: ' ' }); + expect(config.perKey.maxParallel).toBe(DEFAULT_RATE_LIMITS.perKey.maxParallel); + expect(warn).not.toHaveBeenCalled(); + }); + + it('defaults to the values main settled on: 60 rpm, 5 concurrentes', () => { + expect(DEFAULT_RATE_LIMITS.perKey).toEqual({ requestsPerMinute: 60, maxParallel: 5 }); + }); +}); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts new file mode 100644 index 0000000..c454303 --- /dev/null +++ b/src/lib/rateLimits.ts @@ -0,0 +1,76 @@ +/** + * Single source of truth for the rate limits shown in the docs. + * + * Consumed by (what humans read) and by rateLimitsToMd() + * (what /api/docs serves to the Discord bot). Keeping one module means the + * page and the API cannot disagree, which they did: the component said + * 60 rpm while the extractor hardcoded 100 rpm. + * + * Receives the env as a parameter so it can be unit-tested without runtime + * bindings, mirroring src/lib/email.ts. + */ + +export interface PerKeyRateLimits { + requestsPerMinute: number; + maxParallel: number; +} + +export interface ModelRate { + model: string; + label: string; +} + +export interface RateLimitsConfig { + perKey: PerKeyRateLimits; + tokensPerMinuteByModel: ModelRate[]; + requestsPerMinuteByModel: ModelRate[]; +} + +export interface RateLimitsEnv { + RATE_LIMIT_RPM?: string; + RATE_LIMIT_PARALLEL?: string; +} + +/** + * Per-model tables stay here rather than in env vars: they only change when a + * model is added or removed, which is a code change anyway. + */ +export const DEFAULT_RATE_LIMITS: RateLimitsConfig = { + perKey: { requestsPerMinute: 60, maxParallel: 5 }, + tokensPerMinuteByModel: [ + { model: 'deepseek-v4-flash', label: '1.5M tpm' }, + { model: 'mimo-v2.5', label: '1.5M tpm' }, + { model: 'qwen3.6', label: '1.5M tpm' }, + { model: 'gemma4', label: '1.5M tpm' }, + ], + requestsPerMinuteByModel: [{ model: 'rerank', label: '1000 rpm' }], +}; + +function parsePositiveInt(raw: string | undefined, fallback: number, varName: string): number { + if (raw === undefined || raw.trim() === '') return fallback; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + // A misconfigured var must not take the docs down, but it must be loud. + console.warn(`[rateLimits] ignoring invalid ${varName}=${JSON.stringify(raw)}, using ${fallback}`); + return fallback; + } + return n; +} + +export function getRateLimitsConfig(env: RateLimitsEnv = {}): RateLimitsConfig { + return { + ...DEFAULT_RATE_LIMITS, + perKey: { + requestsPerMinute: parsePositiveInt( + env.RATE_LIMIT_RPM, + DEFAULT_RATE_LIMITS.perKey.requestsPerMinute, + 'RATE_LIMIT_RPM', + ), + maxParallel: parsePositiveInt( + env.RATE_LIMIT_PARALLEL, + DEFAULT_RATE_LIMITS.perKey.maxParallel, + 'RATE_LIMIT_PARALLEL', + ), + }, + }; +} diff --git a/src/pages/api/docs/[slug].md.ts b/src/pages/api/docs/[slug].md.ts new file mode 100644 index 0000000..5d5b6d2 --- /dev/null +++ b/src/pages/api/docs/[slug].md.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from 'astro'; +import { getEntry } from 'astro:content'; +import { env } from 'cloudflare:workers'; +import { sha256Hex } from '../../../lib/contentHash'; +import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; +import { mdxToText } from '../../../lib/mdxToText'; +import { getRateLimitsConfig } from '../../../lib/rateLimits'; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request }) => { + const slug = params.slug; + + if (!slug || !SAFE_SLUG.test(slug)) { + return new Response('Invalid slug', { status: 400 }); + } + + try { + const entry = await getEntry('docs', slug); + if (!entry) { + return new Response('Not found', { status: 404 }); + } + + const body = await mdxToText(entry.body ?? '', getRateLimitsConfig(env)); + const contentHash = `sha256:${await sha256Hex(body)}`; + const etag = quoteEtag(contentHash); + + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatchMatches(ifNoneMatch, etag)) { + return new Response(null, { + status: 304, + headers: { + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + 'X-Content-Hash': contentHash, + }, + }); + } + + return new Response(body, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + 'X-Content-Hash': contentHash, + }, + }); + } catch (error) { + console.error(`[api/docs] failed to render ${slug}.md`, error); + return new Response('Internal error', { status: 500 }); + } +}; diff --git a/src/pages/api/docs/manifest.json.ts b/src/pages/api/docs/manifest.json.ts new file mode 100644 index 0000000..2df1391 --- /dev/null +++ b/src/pages/api/docs/manifest.json.ts @@ -0,0 +1,73 @@ +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import { env } from 'cloudflare:workers'; +import { sha256Hex } from '../../../lib/contentHash'; +import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; +import { mdxToText } from '../../../lib/mdxToText'; +import { getRateLimitsConfig } from '../../../lib/rateLimits'; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + try { + const entries = await getCollection('docs'); + for (const entry of entries) { + if (!SAFE_SLUG.test(entry.id)) { + throw new Error(`Doc id is not slug-safe: ${entry.id}`); + } + } + entries.sort((a, b) => { + if (a.data.order !== b.data.order) return a.data.order - b.data.order; + return a.id.localeCompare(b.id); + }); + + const rateLimits = getRateLimitsConfig(env); + const manifestEntries = await Promise.all( + entries.map(async (entry) => { + const text = await mdxToText(entry.body ?? '', rateLimits); + const contentHash = `sha256:${await sha256Hex(text)}`; + return { + slug: entry.id, + title: entry.data.title, + description: entry.data.description, + order: entry.data.order, + contentHash, + contentUrl: `/api/docs/${entry.id}.md`, + }; + }), + ); + + const versionSeed = JSON.stringify(manifestEntries.map((e) => [e.slug, e.contentHash])); + const version = `sha256:${await sha256Hex(versionSeed)}`; + const etag = quoteEtag(version); + + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatchMatches(ifNoneMatch, etag)) { + return new Response(null, { + status: 304, + headers: { + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + }, + }); + } + + const payload = { + version, + entries: manifestEntries, + }; + const body = JSON.stringify(payload, null, 2); + + return new Response(body, { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + }, + }); + } catch (error) { + console.error('[api/docs] failed to build manifest.json', error); + return new Response('Internal error', { status: 500 }); + } +}; diff --git a/src/pages/docs/[...slug].astro b/src/pages/docs/[...slug].astro new file mode 100644 index 0000000..979eac7 --- /dev/null +++ b/src/pages/docs/[...slug].astro @@ -0,0 +1,19 @@ +--- +import { getEntry, render } from 'astro:content'; +import Docs from '../../layouts/Docs.astro'; + +const slugParam = Astro.params.slug; +const slug = slugParam && slugParam.length > 0 ? slugParam : 'intro'; + +const entry = await getEntry('docs', slug); + +if (!entry) { + return new Response('Not found', { status: 404 }); +} + +const { Content } = await render(entry); +--- + + + + diff --git a/src/pages/docs/agents.astro b/src/pages/docs/agents.astro deleted file mode 100644 index 6f65b16..0000000 --- a/src/pages/docs/agents.astro +++ /dev/null @@ -1,253 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; ---- - - -
-

- // agents -

-
- -

Agents.

- -

NaN Cloud te deja desplegar agentes de IA en tu propia microVM: - una máquina virtual ligera con QEMU + KVM, su propio kernel, su propio - filesystem y acceso root completo. Aislada del host y del resto de miembros. El - primer tipo de agente disponible es Hermes.

- - - - -

Arquitectura

- -

Cada agente corre dentro de su propia microVM con QEMU. En lugar de - compartir el kernel del host (como un container normal), arranca con su - propio kernel Linux. La VM monta un disco ext4 de 20 GiB sobre un volumen - en modo block, persistente. Todo lo que haces dentro - —apt install, pip install, edits en - /etc, ficheros que subas, sesiones de bash— vive en ese disco - y sobrevive a reinicios.

- -

El shutdown es graceful: cuando reinicies o borres el agente, el - sistema fuerza un sync y espera a que el journal de ext4 - termine de vaciar antes de matar la VM. Sin corrupciones.

- - - - -

Hermes

- -

Hermes es un agente de IA conversacional que se conecta a Telegram. - Puedes hablar con él, pedirle que gestione notas, ejecute comandos - en su entorno, genere sitios web y mucho más.

- -

1. Crear un bot de Telegram

- -

Necesitas un bot de Telegram. Abre Telegram, busca - @BotFather - y sigue las instrucciones para crear un bot nuevo. Copia el token que te da.

- -

2. Crear el agente

- -

Ve a cloud.nan.builders/agents/new - y rellena: nombre, tipo (Hermes), el token de Telegram, modelo y opcionalmente - un soul (system prompt) que defina la personalidad de tu agente.

- -
- Formulario de creación de agente -
- -

3. Esperar a que esté Running

- -

Tras crear el agente espera ~30 segundos a que el microVM arranque, formatee - el disco la primera vez (mkfs.ext4) y siembre el sistema de - ficheros. El estado pasa a Running y Hermes a Ready.

- -

4. Hablar con tu agente

- -

Busca tu bot en Telegram y envíale un mensaje. Hermes responderá usando - el modelo que hayas configurado.

- -
-
- Conversación con Hermes en Telegram -
-
- -
-

Tu agente está listo.

-

- Con estos 4 pasos ya tienes a Hermes funcionando. Lo que viene a continuación - son funcionalidades adicionales del panel del agente: terminal web, subida de - ficheros, observabilidad, exposición HTTP, Hermes UI y gestión de variables - de entorno. -

-
- - - - -

Console — terminal web

- -

La pestaña Console abre un terminal - interactivo (bash --login) dentro de tu microVM, sin que tengas - que configurar SSH. El stream va sobre WebSocket con xterm.js: resize - automático cuando ajustas el panel, status pill arriba a la derecha y botón - de reconexión si la sesión se cae.

- -

Casos de uso típicos:

- -
    -
  • Instalar paquetes: apt update && apt install -y nginx
  • -
  • Inspeccionar logs internos del agente
  • -
  • Mover ficheros que hayas subido a su ubicación final
  • -
  • Tirar de htop, df -h, journalctl, etc.
  • -
- -
-

- Límites operativos: 1 sesión simultánea por agente · idle timeout 10 min · - duración máxima 30 min por sesión. -

-
- - - - -

Files — subida de ficheros

- -

La pestaña Files permite subir ficheros - al microVM con drag-and-drop o picker. Multi-fichero, cola secuencial, - progress bar en vivo con MiB/s. Los archivos aterrizan en - /persist/uploads/ y desde ahí los puedes mover con la Console.

- -
    -
  • Tamaño máximo: 200 MiB por fichero.
  • -
  • Transporte: WebSocket con chunks de 256 KiB y backpressure end-to-end.
  • -
  • Filename sanitizado server-side (sin path traversal).
  • -
  • Listado en vivo de lo ya subido (refresca cada 5 s).
  • -
- - - - -

Observability

- -

La pestaña Observability agrupa tres - sub-pestañas:

- -
    -
  • Logs — stream en vivo de stdout/stderr - del agente vía WebSocket. Buffer de las últimas 500 líneas en el cliente.
  • -
  • Events — eventos de Kubernetes del Pod - (BackOff, Scheduled, Pulled, Killing...) con tipo, razón, mensaje, edad y - contador. Auto-refresh cada 15 s.
  • -
  • Metrics — uso real de CPU, RAM y disco - contra los límites configurados. CPU/RAM vía Prometheus - (kubelet-cadvisor), disco vía df dentro del microVM (el - filesystem es block-mode, kubelet no lo ve). Refresca cada 10 s.
  • -
- - - - -

Web — exposición pública

- -

La pestaña Web tiene dos sub-pestañas - para sacar servicios HTTP del agente:

- -

HTTP

- -

Cualquier servicio que tu agente sirva por HTTP (nginx, una API, un - static-site) lo puedes exponer públicamente. Por ejemplo, pídele a Hermes - que instale nginx con un HTML personalizado:

- -
-
- Pidiendo a Hermes que instale nginx con un HTML personalizado -
-
- -

En la pestaña Web → HTTP pulsa - Enable HTTP. Por defecto se expone el - puerto 80; si tu servicio escucha en otro puerto, indícalo en - Container Port. La plataforma genera una URL pública en - *.apps.nan.builders.

- -
- Sitio web generado por Hermes visible desde la URL pública -
- -

Hermes UI

- -

Hermes incluye una UI web ligera - (nesquena/hermes-webui) - que se ejecuta siempre dentro del agente. Desde Web → Hermes UI - puedes habilitar acceso externo: la plataforma genera una URL del estilo - webui-<agent>-<user>.apps.nan.builders protegida por - una contraseña per-agent que aparece en el panel.

- - - - -

Variables de entorno

- -

La pestaña Env permite añadir, editar - y borrar variables de entorno del agente sin tocar el Deployment. Útil para - inyectar API keys de terceros, configurar comportamiento de Hermes, etc.

- -

Dos variables son protegidas (sólo - edit, no delete): OPENAI_API_KEY (tu key del cluster, gestionada - por la plataforma) y TELEGRAM_BOT_TOKEN. El resto son - creación / edición / borrado libre.

- - - - -

Recursos y límites

- -

Cada microVM se aprovisiona con:

- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
RecursoRequestLimit
CPU200m1 vCPU
RAM512 Mi2 GiB
Disco—20 GiB (PVC block-mode)
-
- -

CPU y RAM son los límites máximos del - microVM; el uso real suele estar muy por debajo. El disco es persistente — - todo lo que instales o modifiques (paquetes, archivos, configuraciones) - se conserva entre reinicios. Si el disco se llena (90%+), libéralo desde la - Console (du -sh /persist/*).

- -
-

- Actualmente cada miembro puede desplegar 1 agente microVM. - Este límite se ampliará en futuras versiones. -

-
-
diff --git a/src/pages/docs/api.astro b/src/pages/docs/api.astro deleted file mode 100644 index 1a0664f..0000000 --- a/src/pages/docs/api.astro +++ /dev/null @@ -1,1636 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import CodeBlock from '../../components/docs/CodeBlock.astro'; -import RateLimits from '../../components/docs/RateLimits.astro'; ---- - - -
-

- // api -

-
- -

Referencia de la API.

- -

Nuestra API es compatible con OpenAI: cualquier cliente o SDK que acepte un - base URL + API key funciona sin cambios. La base - URL es https://api.nan.builders/v1 y la autenticación es vía - Bearer token. Para obtener tu key, consulta - Empezar.

- -
-
- -
-

Servicio enterprise de Helmcode

-

- Si usas el servicio enterprise de Helmcode recuerda que la URL de la API es api.helmcode.com. El resto de los endpoints es idéntico. -

-
-
-
- - -

Endpoints

- -

- Listado de los endpoints disponibles. Cada uno enlaza a su sección con - request, response y un ejemplo en curl. -

- - - - -

Autenticación

- -

- Todas las peticiones requieren el header Authorization: Bearer <api-key>. - La key es personal e intransferible — consulta - Empezar - para obtener la tuya. -

- - - - -

GET /v1/models

- -

- Devuelve la lista de modelos disponibles para tu key. Modelos publicados: - deepseek-v4-flash, mimo-v2.5, glm5.2, - qwen3.6, gemma4, qwen3-embedding, - rerank, kokoro, whisper, - flux-2-klein (incluye el modelo de imagen flux-2-klein). -

- -

Request

-
-

- Sin body. Solo el header de autenticación. -

-
- -

Response

- - -

Ejemplo

- - - -

POST /v1/chat/completions

- -

- El endpoint principal de chat. Compatible con OpenAI Chat Completions. - Modelos compatibles: deepseek-v4-flash, mimo-v2.5, - glm5.2, qwen3.6 y gemma4. -

- -
-

- capacidades por modelo -

-
-
-
deepseek-v4-flash
-
- Chat, streaming, tool calling, reasoning, contexto de 1M tokens. - Cuota mensual de 500M tokens por miembro. -
-
-
-
mimo-v2.5
-
- Chat, streaming, tool calling, reasoning, vision (image input) y - audio (audio input), contexto de 1M tokens. Cuota mensual de 500M - tokens por miembro. -
-
-
-
glm5.2
-
- Chat, streaming, tool calling, reasoning (emite una traza de - razonamiento), contexto de 256K tokens. Solo texto. Enfocado en - coding y tareas agénticas de largo horizonte. -
-
-
-
qwen3.6
-
- Chat, streaming, tool calling, vision (image input), reasoning - (opt-out, devuelve reasoning_content en el message). -
-
-
-
gemma4
-
- Chat, streaming, vision (image input), reasoning (opt-in). -
-
-
-
- -

Request

-
-
-
-
-
model
- string · required -
-
deepseek-v4-flash, mimo-v2.5, glm5.2, qwen3.6 o gemma4.
-
-
-
-
messages
- array · required -
-
- Lista de mensajes {`{ role, content }`}. content - puede ser string o un array de partes - {`[{type:"text",text}, {type:"image_url",image_url:{url}}]`} - para input multimodal. -
-
-
-
-
max_tokens
- integer · optional -
-
Tope de tokens generados.
-
-
-
-
stream
- boolean · optional -
-
- Default false. Si true, la respuesta llega como SSE. -
-
-
-
-
tools
- array · optional -
-
- Function calling estándar OpenAI: - {`{type:"function",function:{name,description,parameters}}`}. - Validado solo con qwen3.6. -
-
-
-
-
tool_choice
- string | object · optional -
-
- Controla qué tool puede invocar el modelo. Estándar OpenAI. -
-
-
-
-
temperature
- number · optional -
-
Default 0.6.
-
-
-
-
top_p
- number · optional -
-
Default 0.95.
-
-
-
- -

Response

-

- Respuesta sin streaming. finish_reason puede ser stop, - length o tool_calls. -

- -

- El campo reasoning_content se incluye solo cuando se usa - qwen3.6. Es opcional ignorarlo. -

- -

Ejemplo

- - -

Streaming

-

- Con stream: true, la respuesta se entrega como Server-Sent Events. - Cada chunk es data: {`{...}`}\n\n con el delta en - choices[0].delta.content. El stream termina con - data: [DONE]. -

- - -

Tool calling

-

- qwen3.6 soporta function calling estándar OpenAI. Cuando el - modelo decide invocar una tool, la respuesta incluye - choices[0].message.tool_calls con - {`{id, type:"function", function:{name, arguments}}`} y - finish_reason: "tool_calls". -

- - -

Vision

-

- mimo-v2.5, qwen3.6 y gemma4 aceptan - input multimodal. El campo content del mensaje pasa de string a - un array de partes de tipo text y/o image_url. - mimo-v2.5 también acepta input_audio como parte de - content. -

- - -

Structured outputs

- -

Los modelos de chat aceptan el campo response_format estándar - de OpenAI para forzar respuestas JSON válidas. Soportamos los dos modos:

- -
-
-
-
json_object
-
- Garantiza que la respuesta sea JSON sintácticamente válido. No - impone estructura. -
-
-
-
json_schema
-
- Restringe la salida a un JSON Schema concreto. Con strict: true - el modelo no puede emitir campos fuera del schema. -
-
-
-
- -

Funciona en qwen3.6 y gemma4.

- -

- json_object -

- - - -

- json_schema (strict) -

- - - -

- Con el SDK de openai en Python: -

- - - -

Reasoning

- -

Los cinco modelos generan razonamiento y lo devuelven en - choices[0].message.reasoning_content. El mecanismo de control - cambia según el modelo:

- -
-
-
-
qwen3.6
-
chat_template_kwargs.enable_thinking · activo por defecto
-
-
-
gemma4
-
chat_template_kwargs.enable_thinking · desactivado por defecto
-
-
-
deepseek-v4-flash
-
reasoning_effort: low | medium | high · default medium
-
-
-
mimo-v2.5
-
siempre activo · no configurable por API hoy
-
-
-
glm5.2
-
emite reasoning_content · enfoque coding agéntico
-
-
-
- -

- enable_thinking (qwen3.6, gemma4) -

- -

Toggle binario. El campo va en el body del request como - chat_template_kwargs.enable_thinking:

- - - -

- Para desactivarlo en qwen3.6 pasa {`{ "enable_thinking": false }`}. -

- -

- En SDKs como openai de Python o Node, este campo va dentro de - extra_body: -

- - - -

- reasoning_effort (deepseek-v4-flash) -

- -

Parámetro estándar de OpenAI. Acepta low, medium - o high y va como campo top-level del body — no dentro de - extra_body. Si no lo mandas, va en medium por - defecto.

- - - -

- Con el SDK openai de Python: -

- - - -

- A más effort, más tokens dedicados al razonamiento y mejor - calidad en problemas complejos — a cambio de latencia y consumo de tu cuota - mensual. -

- -

mimo-v2.5

- -

MiMo V2.5 razona siempre y emite reasoning_content en cada - respuesta. Hoy el upstream Xiaomi ignora tanto - reasoning_effort como enable_thinking, así que el - nivel de razonamiento no es configurable desde la API. Si necesitas - controlarlo, usa deepseek-v4-flash.

- - -

POST /v1/completions

- -

- Endpoint legacy de OpenAI para text completion. Modelo compatible: - qwen3.6. -

- -

Request

-
-
-
-
-
model
- string · required -
-
qwen3.6.
-
-
-
-
prompt
- string · required -
-
El prompt a completar.
-
-
-
-
max_tokens
- integer · optional -
-
Tope de tokens generados.
-
-
-
-
temperature
- number · optional -
-
Default 0.6.
-
-
-
-
top_p
- number · optional -
-
Default 0.95.
-
-
-
-
stream
- boolean · optional -
-
Default false.
-
-
-
- -

Response

- - -

Ejemplo

- - -

Notas

-
-

- Endpoint legacy de OpenAI. Para conversaciones, usa - /v1/chat/completions. -

-
- - -

POST /v1/embeddings

- -

- Genera embeddings vectoriales. Modelo compatible: - qwen3-embedding. Vectores de 4096 dimensiones. -

- -

Request

-
-
-
-
-
model
- string · required -
-
qwen3-embedding.
-
-
-
-
input
- string | array · required -
-
- Texto único o array de strings a embeddear. -
-
-
-
-
encoding_format
- string · optional -
-
- "float" (default) o "base64". -
-
-
-
- -

Response

- - -

Ejemplo

- - - -

POST /v1/rerank

- -

- Reordena una lista de documentos por relevancia a una query. Modelo - compatible: rerank (Qwen3-Reranker-8B). Completa - el stack RAG junto a qwen3-embedding: primero recuperas top-K - por embeddings, después reordenas con rerank. Soporta 100+ - idiomas, recuperación de código y búsqueda cross-lingual. Endpoint alias: - /v2/rerank. -

- -

Request

-
-
-
-
-
model
- string · required -
-
rerank.
-
-
-
-
query
- string · required -
-
- Consulta contra la que se mide la relevancia de cada documento. -
-
-
-
-
documents
- array · required -
-
- Array de strings a reordenar. La respuesta los devuelve ordenados de - mayor a menor relevance_score con su index - original. -
-
-
-
-
top_n
- integer · optional -
-
- Limita la respuesta a los N documentos más relevantes. - Por defecto devuelve todos. -
-
-
-
- -

Response

- -

- La respuesta incluye id, results (array de - {`{index, relevance_score, document}`}) y meta con - billed_units y conteo de tokens. relevance_score - está en el rango [0, 1]. El index se refiere a la posición - original del documento en el array de entrada. -

- -

Ejemplo

- - -

- Con el SDK de openai de Python (usando post - directo, ya que rerank no forma parte del cliente OpenAI): -

- - - - -

POST /v1/audio/speech

- -

- Sintetiza audio a partir de texto (text-to-speech). Modelo compatible: - kokoro. -

- -

Request

-
-
-
-
-
model
- string · required -
-
kokoro.
-
-
-
-
input
- string · required -
-
- Texto a sintetizar. -
-
-
-
-
voice
- string · required -
-
- Voz a usar. Algunas opciones: af_heart (English female), - ef_dora (Spanish female), em_alex (Spanish male). - Ver listado completo. -
-
-
-
-
response_format
- string · optional -
-
- Formato del audio devuelto. Validados: mp3 (default), - wav, flac, aac, pcm, - opus. -
-
-
-
-
speed
- number · optional -
-
Default 1.0.
-
-
-
- -

Response

-

- Archivo binario de audio en el formato pedido (sin envoltorio JSON). -

- -

Ejemplo

- - - -

POST /v1/audio/transcriptions

- -

- Transcribe audio a texto (speech-to-text). Modelo compatible: - whisper. La petición es multipart/form-data. -

- -

Request

-
-
-
-
-
file
- file · required -
-
Archivo de audio a transcribir.
-
-
-
-
model
- string · required -
-
whisper.
-
-
-
-
language
- string · optional -
-
- Código ISO-639-1 (ej. es, en). Si no se pasa, - se detecta automáticamente. -
-
-
-
-
response_format
- string · optional -
-
- Validados: json (default) y verbose_json. Otros - valores funcionan pero devuelven el contenido envuelto en JSON; - recomendamos solo estos dos. -
-
-
-
-
timestamp_granularities[]
- string · optional -
-
- Solo con verbose_json. Valores: word - (timestamps por palabra) o segment (default). -
-
-
-
-
temperature
- number · optional -
-
Sampling temperature.
-
-
-
- -

Response

-

- Ejemplo con response_format=verbose_json: -

- -

- Si pasas timestamp_granularities[]=word, el campo words - se llena con {`[{word, start, end, probability}]`}. -

- -

Ejemplo

- - -

Limitaciones

-
-
-
-
Tamaño máximo por request — 25 MB
-
- Límite de tamaño del archivo de audio. -
-
-
-
Audios > 2 min pueden devolver timeout 524
-
- Recomendamos dividir en segmentos de ≤ 2 min. -
-
-
-
Formatos recomendados
-
- OGG/Opus y MP3 — mejor compresión, misma calidad - de transcripción. -
-
-
-
- - -

POST /v1/responses

- -

- Endpoint Responses estilo OpenAI. Modelos compatibles: - qwen3.6 y gemma4. -

- -

Request

-
-
-
-
-
model
- string · required -
-
qwen3.6 o gemma4.
-
-
-
-
input
- string | array · required -
-
- Texto único o array de mensajes en formato OpenAI Responses. -
-
-
-
-
max_output_tokens
- integer · optional -
-
- Default 65536 en qwen3.6. -
-
-
-
-
temperature
- number · optional -
-
Default 0.6.
-
-
-
-
top_p
- number · optional -
-
Default 0.95.
-
-
-
-
instructions
- string · optional -
-
Instrucciones de sistema.
-
-
-
- -

Response

-

- El array output puede contener bloques de tipo - reasoning (solo qwen3.6) y message. -

- - -

Ejemplo

- - -

Notas

-
-

- El streaming en este endpoint actualmente entrega un único evento - response.completed al final, no chunks incrementales. Para - streaming token-a-token usa - /v1/chat/completions - con stream: true. -

-
- - -

POST /v1/images/generations

- -

- Genera imágenes a partir de texto (text-to-image). Compatible con la - Images API de OpenAI. Modelo compatible: flux-2-klein (único - modelo disponible hoy; el endpoint está diseñado para añadir más). El body - es JSON. -

- -

Request

-
-
-
-
-
prompt
- string · required -
-
- Descripción textual de la imagen a generar. -
-
-
-
-
model
- string · optional -
-
- Default flux-2-klein (único modelo disponible). Un modelo - desconocido devuelve 404 (model_not_found). -
-
-
-
-
n
- integer · optional -
-
- Número de imágenes a generar, entre 1 y 4. - Default 1. Un valor mayor que 4 devuelve - 400. -
-
-
-
-
size
- string · optional -
-
- Formato "ANCHOxALTO" con ambos lados divisibles por 16, - cada uno entre 256 y 1536, y aspect ratio entre 1:3 y 3:1. Valores - estándar como 1024x1024, 1536x1024 o - 1024x1536 funcionan. "auto" u omitido → - 1024x1024. -
-
-
-
-
response_format
- string · optional -
-
- "url" (default) o "b64_json". Con - url se devuelve un enlace temporal de R2 válido ~60 - minutos (mismo contrato que OpenAI). Con b64_json se - devuelven los bytes de la imagen en base64 inline. -
-
-
-
- -
-

- parámetros aceptados e ignorados -

-

- Por compatibilidad con SDKs de OpenAI se aceptan - quality, style, background, - moderation, output_format, - output_compression y user, pero se - ignoran — Flux no actúa sobre ellos. Además, - stream: true no está soportado y devuelve - 400. -

-
- -

Parámetros adicionales (extensiones NaN)

-

- Estos parámetros no forman parte de la Images API de - OpenAI. Con el SDK de openai se pasan vía - extra_body. -

-
-
-
-
-
seed
- integer · optional -
-
- Seed base para reproducibilidad. Cada variante (cuando - n > 1) parte de un offset sobre este valor. -
-
-
-
-
guidance
- number · optional -
-
- Guidance scale de FLUX. -
-
-
-
- -

Response

-

- Mismo envoltorio que la Images API de OpenAI. created es el - timestamp Unix en segundos. -

- -

- Con response_format=b64_json, cada elemento de data - es {`{ "b64_json": "..." }`} en lugar de - {`{ "url": "..." }`}. -

- -

Ejemplo

- - -

- response_format=b64_json -

- - -

- Con el SDK de openai en Python (las extensiones - seed y guidance van en extra_body): -

- - -

Rate limits y cuota

-

- La generación de imágenes no pasa por LiteLLM, así que los - límites por key de LiteLLM (100 rpm / 5 concurrentes) no le - aplican. Generar imágenes no consume tu presupuesto de rpm del chat, y - viceversa. Estos límites aplican igual a la API y a la consola web, y son - propios de los endpoints de imágenes: -

-
-
-
-
-
Rate limit
- 1 req/s · burst 3 -
-
- 1 request por segundo sostenido, con burst de hasta 3 (puedes disparar - hasta 3 generaciones seguidas sin error). Al excederlo devuelve - 429 (rate_limit_exceeded). -
-
-
-
-
Cuota mensual
- 100 requests / mes -
-
- 100 requests por mes y por usuario (1 request = 1 uso, - independientemente del valor de n). Al excederla devuelve - 429 (insufficient_quota). Esta cuota es - independiente del límite de 500M tokens/mes del chat. -
-
-
-
-
Tier
- inference -
-
- Requiere membresía de tier inference. Las keys de tier community - reciben 403 (tier_restricted). -
-
-
-
- - -

POST /v1/images/edits

- -

- Genera una imagen a partir de una o varias imágenes de referencia - (image-to-image). Compatible con la Images API de OpenAI. Modelo - compatible: flux-2-klein. La petición es - multipart/form-data. Aplican la misma membresía - inference-tier y la misma cuota mensual de 100 requests que - /v1/images/generations. -

- -

Request

-
-
-
-
-
image / image[]
- file · required -
-
- Una o más imágenes de referencia (hasta 4; las extras se descartan). - PNG, JPEG o WebP, cada una < 25 MB. -
-
-
-
-
prompt
- string · required -
-
- Descripción de la edición o transformación a aplicar. -
-
-
-
-
model, n, size, response_format
- optional -
-
- Mismo comportamiento que en - /v1/images/generations. - Las extensiones seed y guidance también se - aceptan (como campos del form). -
-
-
-
- -
-

- El parámetro mask no está soportado y - devuelve 400 — Flux Klein no hace inpainting. -

-
- -

Response

-

- Mismo envoltorio que - /v1/images/generations: - {`{ "created": ..., "data": [{ "url": "..." }] }`} (o elementos - {`{ "b64_json": "..." }`} con - response_format=b64_json). -

- -

Ejemplo

- - - -

Errores

- -

- Los errores siguen el formato estándar de OpenAI: HTTP status no-2xx con un - body JSON describiendo el problema. -

- - - -
-

- códigos comunes -

-
-
-
400
-
- Parámetro inválido — el body incluye param con el campo - que falló (p. ej. prompt, n, - size, stream, mask o - image en los endpoints de imágenes). El filtro de - seguridad devuelve content_policy_violation. -
-
-
-
401
-
- Header Authorization inválido o ausente - (invalid_api_key). -
-
-
-
403
-
- Tu tier no tiene acceso al endpoint (tier_restricted). - La generación de imágenes requiere membresía inference. -
-
-
-
404
-
- Modelo no existe (campo model, - model_not_found). -
-
-
-
429
-
- Rate limit excedido — rpm_limit o - max_parallel_requests - (rate_limit_exceeded), o cuota mensual agotada - (quota_exceeded / insufficient_quota, como - la de 100 requests de imágenes). -
-
-
-
500
-
- Error interno (incluye errores upstream del modelo). -
-
-
-
524
-
- Timeout (típico con audios grandes en - /v1/audio/transcriptions). -
-
-
-
- - -

Rate limits

- -

- Aplican a todas las peticiones por API key. -

- - -
diff --git a/src/pages/docs/apps.astro b/src/pages/docs/apps.astro deleted file mode 100644 index 1f4025d..0000000 --- a/src/pages/docs/apps.astro +++ /dev/null @@ -1,208 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; ---- - - -
-

- // apps -

-
- -

Apps.

- -

NaN Cloud te permite desplegar tus propias apps desde un repositorio - de GitHub: construimos tu imagen, la publicamos en un entorno aislado tuyo y la - servimos detrás de un dominio público con HTTPS. Todo en un clic.

- -
-

Antes de empezar

-

- Las Apps viven dentro de un Space: tu propio - entorno con su cuota de recursos. Si tienes la suscripción de inferencia activa, - recibes un Space Basic gratis incluido en tu - membresía. Si no, puedes comprar uno desde - cloud.nan.builders/spaces. -

-
- -

Tiers disponibles

- -

Cada Space pertenece a un tier. El tier define la cuota total de CPU, RAM - y almacenamiento que se reparte entre todas las apps que despliegues - dentro de él. Puedes subir o bajar de tier - en cualquier momento desde el dashboard del Space (la bajada solo se - permite si tu uso actual cabe en el tier nuevo).

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TierCPURAMDiscoPodsPrecio
Basic2 vCPU4 GiB20 GiB5Gratis con inferencia · $6 / €6 al mes
Medium4 vCPU8 GiB40 GiB10$12 / €12 al mes
Large4 vCPU16 GiB80 GiB20$24 / €24 al mes
-
- -

CPU y RAM son los topes - agregados del Space (suma de todas tus apps). Por defecto cada app que - crees arranca con un límite cómodo de 500m CPU y 500 MiB - de RAM, suficiente para una API o worker típico; puedes subir el límite por app - desde la sección Advanced options del formulario hasta consumir el tier - completo. El disco se reparte vía PVCs (5/10/20 según tier) y solo lo usan las - apps que marques como persistent.

- - - - -

1. Crear un Space

- -

Entra en cloud.nan.builders/spaces. - Si eres miembro de inferencia verás un panel que te ofrece reclamar tu Space Basic gratis: - elige un slug (entre 1 y 20 caracteres, minúsculas, sin espacios) y pulsa - Claim free Basic. El slug se usará para - construir los dominios públicos de tus apps, así que escógelo con cariño.

- -
- Reclamar un Space Basic gratuito -
- -

El Space se activa al instante.

- - - - -

2. Crear una App dentro del Space

- -

Abre tu Space recién creado. Verás el resumen de recursos consumidos, el - botón Change plan por si quieres subir - de tier en algún momento, y la sección Apps in - this Space. Pulsa New App - para arrancar el formulario.

- -
- Crear una nueva App dentro del Space -
- - - - -

3. Conectar GitHub y configurar la build

- -

Conecta tu cuenta de GitHub autorizando la NaN Cloud GitHub App al repositorio - que vas a desplegar (la primera vez te lleva al flujo oficial de instalación - en github.com). Una vez conectado, selecciona el repo de la lista, elige - la rama y dale un nombre a tu App.

- -
-

Requisito imprescindible: Dockerfile

-

- Tu repositorio debe contener un Dockerfile - en la raíz (o en el path que configures). Sin Dockerfile no podemos construir tu - imagen y la app no se desplegará. Así tienes control total sobre el runtime, - las dependencias y los procesos que arrancan dentro de tu app. -

-
- -

Si tu app es un servicio HTTP (página web, API, panel admin, etc.), marca - Expose over HTTP e indica el - puerto en el que tu app escucha - internamente. Por ejemplo, si arrancas con node server.js - escuchando en :8080, pon 8080 aquí. Nosotros nos - encargamos de publicarla en un dominio público con HTTPS.

- -

Si tu app es un proceso que no necesita ser accesible desde fuera - (un worker, un cron, un consumer de cola...), desmarca Expose over HTTP: - la app arrancará en modo worker, sin URL pública.

- -
- Formulario de creación de App: GitHub + Dockerfile + puerto -
- -

El bloque Environment variables (opcional) te - deja añadir variables tanto de tiempo de ejecución como de build. Y en - Advanced options puedes ajustar réplicas, - CPU/memoria y añadir almacenamiento persistente si tu app necesita guardar - estado.

- -

Pulsa Deploy. En la pantalla de detalle de - la App verás la build en directo. Tras el build, si todo ha ido bien, verás - que el estado pasa a Running.

- - - - -

4. Abrir tu App

- -

Cuando el estado sea Running, pulsa el botón - Open arriba a la derecha. Te abre la URL - pública de tu app en una pestaña nueva.

- -
- App en estado Running con botón Open -
- -

Desde la misma pantalla tienes acceso a los logs de tu app en directo, - sus eventos, métricas (CPU, memoria, disco), gestión de variables de - entorno y un panel de ajustes para mutar rama, Dockerfile, puerto y - recursos en caliente.

- - - - -

5. Tu app, en producción

- -

Y eso es todo. Tu repositorio de GitHub está sirviendo tráfico real desde - un dominio público con HTTPS, sobre infra nuestra. Cada git push - a la rama configurada (con auto-deploy activado) dispara una nueva build - automáticamente.

- -
- Ejemplo de App desplegada y servida -
- -
-

Tu App está viva.

-

- Con estos 5 pasos ya tienes tu app desplegada. Si necesitas escalar - (más recursos, más réplicas, almacenamiento persistente, más Spaces - para separar entornos dev/staging/prod), puedes hacerlo en cualquier - momento desde el dashboard. Apps y Spaces están en - Beta — si encuentras algún - problema, repórtalo en #support en Discord. -

-
- -
-
diff --git a/src/pages/docs/getting-started.astro b/src/pages/docs/getting-started.astro deleted file mode 100644 index d6490a2..0000000 --- a/src/pages/docs/getting-started.astro +++ /dev/null @@ -1,78 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import CodeBlock from '../../components/docs/CodeBlock.astro'; ---- - - -
-

- // getting started -

-
- -

Conectarse.

- -

El acceso es vía LiteLLM con una API compatible con OpenAI. Funciona con - cualquier herramienta que acepte un base URL - + API key: Cursor, Cline, Continue, Aider, Open Code, - Open WebUI o cualquier SDK compatible con OpenAI.

- - -

Obtener tu API Key

- -
-

- Debes estar dentro de la comunidad NaN. Si ya estás suscrito, - genera tu API Key desde la sección de ajustes del usuario en el apartado "API Keys" - de la plataforma. - La key es personal e intransferible. -

-
- - El soporte es solo para temas técnicos -
-
- - -

Configurar tu herramienta

- -

- Usa estos valores en tu IDE o herramienta: -

- -
-
-
base URL
-
- https://api.nan.builders/v1 -
-
-
-
API Key
-
- sk-tu-key-aqui -
-
-
-
Model
-
- qwen3.6 -
-
-
- -
-

- ejemplo: config en OpenAI-compatible -

- -
-
diff --git a/src/pages/docs/index.astro b/src/pages/docs/index.astro deleted file mode 100644 index ae5beff..0000000 --- a/src/pages/docs/index.astro +++ /dev/null @@ -1,73 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import RateLimits from '../../components/docs/RateLimits.astro'; ---- - - -
-

- // docs -

-
- -

Bienvenido a NaN.

- -

Esta doc explica cómo conectar tus herramientas a nuestras GPUs. El cluster - corre modelos abiertos con una API compatible con OpenAI. Si algo - acepta un base URL + API key, - funciona con NaN.

- -
-
- -
-

Para obtener tu API Key

-

- Debes estar dentro de la comunidad NaN. Puedes generar tu API Key - desde la sección de ajustes del usuario en el apartado "API Keys" - de la plataforma. - La key es personal e intransferible. -

-
-
-
- - -

- rates -

- - -

- qué hacer a continuación -

- - - -
-
diff --git a/src/pages/docs/models.astro b/src/pages/docs/models.astro deleted file mode 100644 index afacc8a..0000000 --- a/src/pages/docs/models.astro +++ /dev/null @@ -1,674 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import RateLimits from '../../components/docs/RateLimits.astro'; ---- - - -
-

- // models -

-
- -

Models del cluster.

- -

Los modelos de la comunidad. Todos se acceden por la misma API OpenAI-compatible - con el mismo base URL.

- - -

deepseek-v4-flash - 284B-21B

- -
-
-

- generación de texto y chat -

-

- Modelo MoE de 284B parámetros (21B activos). Contexto de 1M tokens. - Tool calling y reasoning. Cuota mensual de 500M tokens por miembro. -

-
-
-
Tipo
-
MoE (284B total · 21B active)
-
-
-
Cuantización
-
FP8
-
-
-
Contexto
-
1M tokens
-
-
-
Cuota mensual
-
500M tokens / miembro
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling -
  • -
  • - - Reasoning mode -
  • -
  • - - Contexto de 1M tokens -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

mimo-v2.5 - 310B-15B

- -
-
-

- omnimodal — texto, visión y audio -

-

- Modelo MoE de 310B parámetros (15B activos), omnimodal nativo con encoders - dedicados de visión y audio. Contexto de 1M tokens. Tool calling y reasoning. - Cuota mensual de 500M tokens por miembro. Licencia MIT. -

-
-
-
Tipo
-
MoE (310B total · 15B active)
-
-
-
Cuantización
-
FP8
-
-
-
Contexto
-
1M tokens
-
-
-
Modalidades input
-
text · image · audio
-
-
-
Modalidades output
-
text
-
-
-
Cuota mensual
-
500M tokens / miembro
-
-
-
Licencia
-
MIT
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling (function calling) -
  • -
  • - - Reasoning mode (recomendado max_tokens ≥ 300) -
  • -
  • - - Visión (image input) -
  • -
  • - - Audio (audio input) -
  • -
  • - - Contexto de 1M tokens -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

glm5.2 - 753B MoE

- -
-
-

- generación de texto y chat — coding agéntico -

-

- Modelo MoE de ~753B parámetros, enfocado en coding y tareas agénticas - de largo horizonte. Contexto de 256K tokens. Tool calling y reasoning - (emite una traza de razonamiento). Solo texto. -

-
-
-
Tipo
-
MoE (~753B total)
-
-
-
Cuantización
-
FP8
-
-
-
Atención
-
Sparse attention
-
-
-
Contexto
-
256K tokens
-
-
-
Modalidades input
-
text
-
-
-
Modalidades output
-
text
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling (function calling) -
  • -
  • - - Reasoning mode (traza de razonamiento) -
  • -
  • - - Coding y tareas agénticas de largo horizonte -
  • -
  • - - Contexto de 256K tokens -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

gemma4 - 26B-A4B

- -
-
-

- generación de texto y chat -

-

- Modelo MoE de 26B parámetros (4B activos), multimodal con visión. Tool calling y reasoning. -

-
-
-
Tipo
-
MoE (26B total · 4B active)
-
-
-
Cuantización
-
FP8
-
-
-
Contexto
-
256K tokens
-
-
-
Sampling
-
temp=0.6, top_p=0.95
-
-
-
Reasoning
-
reasoning_config={}
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling (formato XML) -
  • -
  • - - Reasoning mode -
  • -
  • - - Multimodal (vision / imágenes) -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

qwen3.6 - 35B-A3B

- -
-
-

- generación de texto y chat -

-

- El modelo principal. MoE de 35B parámetros, multimodal, con - tool calling y reasoning. -

-
-
-
Tipo
-
MoE (35B total)
-
-
-
Activo por token
-
3B
-
-
-
Cuantización
-
FP8
-
-
-
Contexto
-
256K tokens
-
-
-
Speculative decoding
-
MTP → ~2x throughput
-
-
-
Sampling
-
temp=0.6, top_p=0.95
-
-
-
Reasoning
-
reasoning_config={}
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling (formato XML) -
  • -
  • - - Reasoning mode -
  • -
  • - - Multimodal (vision / imágenes) -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

qwen3-embedding - 8B

- -
-
-

- embeddings vectoriales -

-

- Modelo de embedding vectorial. MMTEB score 70.58 — top modelos abiertos. - Soporta 100+ idiomas incluyendo español y código. -

-
-
-
Dimensión
-
4096
-
-
-
Precisión
-
Float32 (CPU)
-
-
-
RPM
-
60
-
-
-
Batch size
-
32
-
-
-
- -
-

- casos de uso -

-
    -
  • - - Similitud cross-lingual (ES↔EN: 0.915) -
  • -
  • - - Búsqueda semántica -
  • -
  • - - Clasificación de texto -
  • -
  • - - RAG / retrieval aumentado -
  • -
-
-
- - -

rerank - Qwen3-Reranker-8B

- -
-
-

- reranking semántico -

-

- Modelo de reranking de 8B parámetros (BF16). Reordena una lista de - documentos por relevancia a una query. Completa el stack RAG junto a - qwen3-embedding: primero recuperas top-K por embeddings, - después reordenas con rerank para precisión. Soporta 100+ - idiomas incluyendo español, recuperación de código y cross-lingual. - Top-tier en benchmarks MTEB de reranking. -

-
-
-
Parámetros
-
8B
-
-
-
Precisión
-
BF16
-
-
-
Endpoints
-
/v1/rerank · /v2/rerank
-
-
-
Idiomas
-
100+
-
-
-
- -
-

- casos de uso -

-
    -
  • - - Reranking en pipelines RAG (embedding → rerank → LLM) -
  • -
  • - - Búsqueda cross-lingual (ES↔EN, etc.) -
  • -
  • - - Recuperación de código -
  • -
  • - - Scoring de relevancia query-documento -
  • -
-
-
- - -

kokoro - v1.0

- -
-
-

- text-to-speech -

-

- TTS de 82M params con 67 voice packs. Sub-second latency en CPU. -

-
-
-
Latencia
-
< 1s
-
-
-
Partes
-
82M
-
-
-
RPM
-
15
-
-
-
- -
-

- voces disponibles -

-
    -
  • - -
    - af_heart — English (female) -
    -
  • -
  • - -
    - ef_dora — Spanish (female) -
    -
  • -
  • - -
    - em_alex — Spanish (male) -
    -
  • -
  • - - 67 voice packs en total (ver listado completo) -
  • -
-
-
- - -

whisper - large-v3

- -
-
-

- speech-to-text -

-

- STT en CPU con CTranslate2 e INT8. ~1x realtime. 99+ idiomas. -

-
-
-
Tamaño
-
~3 GB (INT8)
-
-
-
WER ES
-
~3.2%
-
-
-
RPM
-
10
-
-
-
- -
-

- capacidades -

-
    -
  • - - Transcripción de audio a texto -
  • -
  • - - 99+ idiomas -
  • -
  • - - Detección de idioma automática -
  • -
  • - - API OpenAI-compatible -
  • -
-
-
- -
-

- limitaciones conocidas -

-
-
-
File size limit — 25 MB
-
- Tamaño máximo por request. Formatos comprimidos (OGG/Opus, MP3) aprovechan - mejor este límite que WAV sin comprimir. -
-
-
-
Timeout — audios > 2 min de duración
-
- Whisper procesa en CPU a ~1x realtime. Para audios de más de ~2 minutos, - el proxy puede devolver un error 524 (timeout) antes de que termine - la transcripción. Usa formatos comprimidos como OGG/Opus y divide - archivos largos en segmentos de ≤ 2 minutos para evitarlo. -
-
-
-
Formatos recomendados
-
- OGG/Opus y MP3 — archivos más pequeños, misma calidad de - transcripción. Un audio de 60 min en OGG/Opus a 48 kbps ocupa ~20 MB vs ~550 MB en WAV. -
-
-
-
- - -

flux-2-klein

- -
-
-

- generación de imágenes -

-

- Modelo de difusión FLUX para text-to-image e image-to-image. Compatible - con la Images API de OpenAI (/v1/images/generations y - /v1/images/edits). Requiere membresía inference-tier. -

-
-
-
Tipo
-
Diffusion (FLUX)
-
-
-
Modalidades
-
text→image · image→image
-
-
-
Resolución
-
256–1536 px (múltiplos de 16)
-
-
-
Imágenes / request
-
1–4 (n)
-
-
-
Cuota mensual
-
100 requests / miembro
-
-
-
- -
-

- capacidades -

-
    -
  • - - Text-to-image (/v1/images/generations) -
  • -
  • - - Image-to-image con hasta 4 referencias (/v1/images/edits) -
  • -
  • - - Salida como URL temporal (R2, ~60 min) o base64 -
  • -
  • - - Reproducibilidad por seed y control de guidance -
  • -
-
-
- - -
diff --git a/src/styles/global.css b/src/styles/global.css index f7496dc..909ba5f 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -97,7 +97,7 @@ font-weight: 600; } -.docs-content code:not(.code-block code):not([class*="bg-neutral-900"]) { +.docs-content :not(pre) > code:not(.code-block code):not([class*="bg-neutral-900"]) { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.8rem; color: rgb(212 212 216); @@ -242,3 +242,146 @@ opacity: 1; transform: translateX(-50%) translateY(0); } + +.docs-content h3 { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.8rem; + line-height: 1.6; + color: rgb(226 232 240); + margin-top: 2rem; + margin-bottom: 0.75rem; +} + +.docs-content a { + color: rgb(167 139 250); + text-decoration: underline; + text-underline-offset: 2px; +} + +.docs-content a:hover { + color: rgb(196 181 253); +} + +.docs-content ul, +.docs-content ol { + margin: 0 0 1.5rem 1.25rem; + color: rgb(203 213 225); +} + +.docs-content li { + margin-bottom: 0.5rem; + line-height: 1.75; +} + +.docs-content blockquote { + margin: 0 0 1.5rem 0; + padding: 1rem 1.25rem; + border: 1px solid rgb(38 38 38 / 0.6); + border-left: 3px solid rgb(139 92 246); + border-radius: 0.75rem; + background: rgb(10 10 10); + box-shadow: 0 0 0 1px rgb(139 92 246 / 0.08) inset; +} + +.docs-content blockquote p:last-child { + margin-bottom: 0; +} + +.docs-content table { + width: 100%; + margin-bottom: 1.5rem; + border-collapse: collapse; + font-size: 0.875rem; + display: block; + overflow-x: auto; +} + +.docs-content thead tr { + border-bottom: 1px solid rgb(38 38 38 / 0.6); +} + +.docs-content tbody tr { + border-bottom: 1px solid rgb(38 38 38 / 0.3); +} + +.docs-content th, +.docs-content td { + padding: 0.75rem 1rem 0.75rem 0; + text-align: left; + vertical-align: top; +} + +.docs-content th { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgb(115 115 115); +} + +.docs-content td { + color: rgb(212 212 216); +} + +.docs-code-block { + margin: 0 0 1.5rem 0; + border: 1px solid rgb(38 38 38 / 0.6); + border-radius: 0.75rem; + background: rgb(10 10 10); + overflow: hidden; +} + +.docs-code-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgb(38 38 38 / 0.6); + background: rgb(10 10 10); +} + +.docs-code-label { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgb(167 139 250); +} + +.docs-code-toolbar .copy-btn { + border: 0; + background: transparent; + color: rgb(163 163 163); + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; +} + +.docs-code-toolbar .copy-btn:hover { + color: rgb(226 232 240); +} + +.docs-content pre { + margin: 0; + padding: 1rem 1.25rem; + overflow-x: auto; + background: rgb(10 10 10); +} + +.docs-content pre code { + background: transparent; + padding: 0; + border-radius: 0; +} + +.docs-content img { + display: block; + width: 100%; + height: auto; + margin: 1.5rem 0; + border: 1px solid rgb(38 38 38 / 0.6); + border-radius: 0.75rem; +} diff --git a/src/tests/api/docs.test.ts b/src/tests/api/docs.test.ts new file mode 100644 index 0000000..dc56bee --- /dev/null +++ b/src/tests/api/docs.test.ts @@ -0,0 +1,123 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startDocsApiServer, type DocsApiServer } from '../helpers/docsApiServer'; + +let server: DocsApiServer; + +beforeAll(async () => { + server = await startDocsApiServer(); +}, 60_000); + +afterAll(async () => { + if (server) await server.stop(); +}); + +interface ManifestEntry { + slug: string; + title: string; + description: string; + order: number; + contentHash: string; + contentUrl: string; +} + +interface Manifest { + version: string; + entries: ManifestEntry[]; +} + +describe('GET /api/docs/manifest.json', () => { + it('returns 200 with a valid manifest payload', async () => { + const res = await server.fetch('/api/docs/manifest.json'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/application\/json/i); + expect(res.headers.get('cache-control')).toBe('public, max-age=900, s-maxage=900'); + const payload = (await res.json()) as Manifest; + expect(payload.version).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(payload.entries.length).toBeGreaterThan(0); + for (const entry of payload.entries) { + expect(entry.slug).toMatch(/^[a-z0-9][a-z0-9-]{0,63}$/); + expect(entry.contentHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(entry.contentUrl).toBe(`/api/docs/${entry.slug}.md`); + } + }); + + it('is ordered by (order, slug)', async () => { + const res = await server.fetch('/api/docs/manifest.json'); + const payload = (await res.json()) as Manifest; + const expected = [...payload.entries] + .map((e) => ({ slug: e.slug, order: e.order })) + .sort((a, b) => (a.order !== b.order ? a.order - b.order : a.slug.localeCompare(b.slug))); + expect(payload.entries.map((e) => e.slug)).toEqual(expected.map((e) => e.slug)); + }); + + it('has a stable version and ETag across hits', async () => { + const a = await server.fetch('/api/docs/manifest.json'); + const b = await server.fetch('/api/docs/manifest.json'); + const versionA = ((await a.json()) as Manifest).version; + const versionB = ((await b.json()) as Manifest).version; + expect(versionA).toBe(versionB); + expect(a.headers.get('etag')).toBe(b.headers.get('etag')); + }); + + it('returns 304 on matching If-None-Match', async () => { + const first = await server.fetch('/api/docs/manifest.json'); + const etag = first.headers.get('etag')!; + expect(etag).toBeTruthy(); + const second = await server.fetch('/api/docs/manifest.json', { + headers: { 'If-None-Match': etag }, + }); + expect(second.status).toBe(304); + expect(second.headers.get('etag')).toBe(etag); + }); + + it('matches per-entry contentHash with the body endpoint hash', async () => { + const res = await server.fetch('/api/docs/manifest.json'); + const payload = (await res.json()) as Manifest; + const entry = payload.entries[0]; + const bodyRes = await server.fetch(entry.contentUrl); + expect(bodyRes.status).toBe(200); + expect(bodyRes.headers.get('x-content-hash')).toBe(entry.contentHash); + }); +}); + +describe('GET /api/docs/[slug].md', () => { + it('returns 400 for an invalid slug', async () => { + const res = await server.fetch('/api/docs/Bad-Slug.md'); + expect(res.status).toBe(400); + }); + + it('returns 404 for an unknown slug', async () => { + const res = await server.fetch('/api/docs/nonexistent.md'); + expect(res.status).toBe(404); + }); + + it('returns 200 with markdown content for a valid slug', async () => { + const res = await server.fetch('/api/docs/intro.md'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/text\/markdown/i); + expect(res.headers.get('cache-control')).toBe('public, max-age=900, s-maxage=900'); + const body = await res.text(); + expect(body).not.toMatch(/^---\s*\n/); + expect(body).not.toMatch(/^import\s/m); + expect(body).not.toMatch(/^export\s/m); + expect(body.replace(/```[\s\S]*?```/g, '')).not.toMatch(/<[a-zA-Z]/); + }); + + it('coheres ETag with X-Content-Hash', async () => { + const res = await server.fetch('/api/docs/intro.md'); + const etag = res.headers.get('etag')!; + const hash = res.headers.get('x-content-hash')!; + expect(etag).toBe(`"${hash}"`); + }); + + it('returns 304 on matching If-None-Match', async () => { + const first = await server.fetch('/api/docs/intro.md'); + const etag = first.headers.get('etag')!; + expect(etag).toBeTruthy(); + const second = await server.fetch('/api/docs/intro.md', { + headers: { 'If-None-Match': etag }, + }); + expect(second.status).toBe(304); + expect(second.headers.get('etag')).toBe(etag); + }); +}); diff --git a/src/tests/helpers/docsApiServer.ts b/src/tests/helpers/docsApiServer.ts new file mode 100644 index 0000000..479775a --- /dev/null +++ b/src/tests/helpers/docsApiServer.ts @@ -0,0 +1,29 @@ +import { dev, type AstroInlineConfig } from 'astro'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type DevServer = Awaited>; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +export interface DocsApiServer { + baseUrl: string; + fetch: (path: string, init?: RequestInit) => Promise; + stop: () => Promise; +} + +export async function startDocsApiServer(): Promise { + const config: AstroInlineConfig = { + root: projectRoot, + logLevel: 'error', + server: { host: '127.0.0.1', port: 0 }, + }; + const server: DevServer = await dev(config); + const address = server.address; + const baseUrl = `http://127.0.0.1:${address.port}`; + return { + baseUrl, + fetch: (p: string, init?: RequestInit) => fetch(`${baseUrl}${p}`, init), + stop: () => server.stop(), + }; +} diff --git a/tsconfig.json b/tsconfig.json index a250963..6368186 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,9 @@ "./worker-configuration.d.ts" ], "exclude": [ - "dist" + "dist", + "src/**/*.test.ts", + "src/tests" ], "compilerOptions": { "jsx": "react-jsx", diff --git a/vitest.config.ts b/vitest.config.ts index 7dd1325..4452073 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts'], + fileParallelism: false, }, }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 3a09d62..1ca66a7 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,6 +1,6 @@ { "compatibility_date": "2026-03-17", - "compatibility_flags": ["global_fetch_strictly_public"], + "compatibility_flags": ["global_fetch_strictly_public", "nodejs_compat"], "name": "nan-website", "main": "@astrojs/cloudflare/entrypoints/server", "assets": { @@ -9,5 +9,13 @@ }, "observability": { "enabled": true + }, + // Per-key rate limits shown in the docs and served to the Discord bot. + // Not secrets. Kept here so the deployed values are reproducible from the + // repo; src/lib/rateLimits.ts falls back to the same numbers if they are + // missing. + "vars": { + "RATE_LIMIT_RPM": "60", + "RATE_LIMIT_PARALLEL": "5" } }