From 12bcc261fbb1d5013636367cd39667e6db817b55 Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Wed, 12 Aug 2026 12:12:28 +0200 Subject: [PATCH 01/15] docs(api): serve the API reference with Scalar (#16) /docs/api now renders from an OpenAPI spec instead of the 1,154 hand-written lines of api.mdx, which drifted from the backend on every endpoint change. Updating an endpoint is now updating the spec. The spec starts from helmcode.com's and is rebranded to NaN, but not only the branding: the nine resold models (claude-*, gpt-*, gemini-*), the prepaid credit billing and the 503 from their metering layer are gone, because NaN has none of them. The 402 is rewritten as what it actually is here, the glm5.2 cap. mimo-v2.5, glm5.2 and the /mcp endpoint are added, which helmcode does not document. The surface was verified against api.nan.builders by probing every route: the 12 documented ones answer 401 (they exist and want auth) while moderations, batches and files answer 404, so they stay out. The /v1/search limits were also corrected, as they carried helmcode's numbers (60 rpm, 3,000/day, 10 concurrent) instead of the real ones (20 rpm, 500/day, 3). On the Discord bot: with api.mdx out of the collection the manifest would have lost its `api` entry, and the bot does not fail on that, it silently drops the chunks. We would have lost the content it gets asked about the most with no alarm at all. Serving the raw spec was no good either: it chunks by paragraph at 2,000 characters, and a JSON cut that way yields meaningless embeddings. So openapiToText generates the Markdown from the spec and /api/docs serves it under the same slug, order and contract as before. Verified end to end with the bot's own canonicaliser: all seven hashes match and the reference splits into 28 clean chunks. The theme is wired to src/styles/tokens.css rather than inheriting loose hex, which helmcode left pending. "Ask AI" and "Generate MCP" are turned off by configuration rather than by hiding the button: the component mounted anyway and called api.scalar.com on every load. With withDefaultFonts: false the page contacts no third party, which is the reason the rest of the site self-hosts its fonts. "Try it" goes straight to api.nan.builders (open CORS) without Scalar's proxy, so a member's key never travels to a third party. - /docs/api and /es/docs/api, without noindex and in the sitemap with hreflang - the agents.md link to #mcp becomes #tag/mcp, Scalar's anchor - a tripwire over the spec: surface, model catalogue, and nothing from helmcode creeping back in --- package-lock.json | 2277 ++++++++++++++++++++- package.json | 1 + src/components/docs/ApiReference.astro | 336 ++++ src/content/docs/agents.md | 2 +- src/content/docs/api.mdx | 1154 ----------- src/data/openapi.json | 2539 ++++++++++++++++++++++++ src/layouts/Docs.astro | 22 +- src/lib/apiDoc.ts | 36 + src/lib/canonicalParity.test.ts | 28 + src/lib/docsManifestRoute.test.ts | 42 +- src/lib/openapiSpec.test.ts | 154 ++ src/lib/openapiToText.ts | 329 +++ src/pages/api/docs/[slug].md.ts | 16 +- src/pages/api/docs/manifest.json.ts | 50 +- src/pages/docs/api.astro | 11 + src/pages/es/docs/api.astro | 13 + src/pages/openapi.json.ts | 23 + src/pages/sitemap.xml.ts | 16 + src/styles/scalar-theme.css | 89 + src/tests/lib/docsGlm52.test.ts | 53 +- src/tests/lib/sitemap.test.ts | 26 +- 21 files changed, 5964 insertions(+), 1253 deletions(-) create mode 100644 src/components/docs/ApiReference.astro delete mode 100644 src/content/docs/api.mdx create mode 100644 src/data/openapi.json create mode 100644 src/lib/apiDoc.ts create mode 100644 src/lib/openapiSpec.test.ts create mode 100644 src/lib/openapiToText.ts create mode 100644 src/pages/docs/api.astro create mode 100644 src/pages/es/docs/api.astro create mode 100644 src/pages/openapi.json.ts create mode 100644 src/styles/scalar-theme.css diff --git a/package-lock.json b/package-lock.json index 2c7857a..021faee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@astrojs/preact": "^5.0.2", "@fontsource-variable/archivo": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", + "@scalar/api-reference": "^1.64.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "astro-i18n": "^2.2.4", @@ -38,6 +39,69 @@ "node": ">=22.12.0" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.13.tgz", + "integrity": "sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.5", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.2.tgz", + "integrity": "sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.5.tgz", + "integrity": "sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/vue": { + "version": "3.0.33", + "resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-3.0.33.tgz", + "integrity": "sha512-czM9Js3a7f+Eo35gjEYEeJYUoPvMg5Dfi4bOLyDBghLqn0gaVg8yTmTaSuHCg+3K/+1xPjyXd4+2XcQIohWWiQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.5", + "ai": "6.0.33", + "swrv": "^1.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "vue": "^3.3.4" + } + }, "node_modules/@astrojs/check": { "version": "0.9.8", "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.8.tgz", @@ -451,18 +515,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -491,12 +555,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -587,13 +651,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -761,6 +825,160 @@ "devOptional": true, "license": "MIT OR Apache-2.0" }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.8.tgz", + "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1271,6 +1489,80 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/core/node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.9.tgz", + "integrity": "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4", + "@floating-ui/utils": "^0.2.10", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@fontsource-variable/archivo": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz", @@ -1289,6 +1581,33 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz", + "integrity": "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "tailwindcss": "^3.0 || ^4.0" + } + }, + "node_modules/@headlessui/vue": { + "version": "1.7.23", + "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", + "integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==", + "license": "MIT", + "dependencies": { + "@tanstack/vue-virtual": "^3.0.0-beta.60" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1754,6 +2073,24 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@internationalized/date": { + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", + "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz", + "integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1799,6 +2136,102 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.6.tgz", + "integrity": "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -1845,12 +2278,27 @@ "@types/estree": "^1.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@phosphor-icons/core": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@phosphor-icons/core/-/core-2.1.1.tgz", + "integrity": "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==", + "license": "MIT" + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -1988,6 +2436,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@replit/codemirror-css-color-picker": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-css-color-picker/-/codemirror-css-color-picker-6.3.0.tgz", + "integrity": "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A==", + "license": "MIT", + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -2335,51 +2794,565 @@ "win32" ] }, - "node_modules/@shikijs/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", - "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "node_modules/@scalar/agent-chat": { + "version": "0.12.23", + "resolved": "https://registry.npmjs.org/@scalar/agent-chat/-/agent-chat-0.12.23.tgz", + "integrity": "sha512-jBddLcMpID7MFoDzEMRXDiu+OIbzgU8OzdJNFXrpndDlsEssC3gDMz+2ztgIqwrXPOir8CMXFt66K+jFs15ZKQ==", + "license": "MIT", + "dependencies": { + "@ai-sdk/vue": "3.0.33", + "@scalar/api-client": "3.14.0", + "@scalar/components": "0.27.10", + "@scalar/helpers": "0.9.2", + "@scalar/icons": "0.7.5", + "@scalar/json-magic": "0.12.19", + "@scalar/openapi-types": "0.9.4", + "@scalar/schemas": "0.8.0", + "@scalar/themes": "0.17.2", + "@scalar/types": "0.17.0", + "@scalar/use-toasts": "0.10.4", + "@scalar/validation": "0.6.2", + "@scalar/workspace-store": "0.56.0", + "@vueuse/core": "13.9.0", + "ai": "6.0.33", + "js-base64": "^3.7.8", + "neverpanic": "0.0.8", + "truncate-json": "3.0.1", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-client": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@scalar/api-client/-/api-client-3.14.0.tgz", + "integrity": "sha512-GX97fACHV2ht3/IpSUYJw7ozrDtszcBw+O0uKl6XlD33aKvzCHqvNRLvf/JgLGeZpyqFkJEh/jsBJwPqBUarsA==", + "license": "MIT", + "dependencies": { + "@headlessui/tailwindcss": "^0.2.2", + "@headlessui/vue": "1.7.23", + "@scalar/blocks": "0.1.9", + "@scalar/components": "0.27.10", + "@scalar/helpers": "0.9.2", + "@scalar/icons": "0.7.5", + "@scalar/oas-utils": "0.19.9", + "@scalar/openapi-types": "0.9.4", + "@scalar/sidebar": "0.9.34", + "@scalar/snippetz": "0.9.24", + "@scalar/themes": "0.17.2", + "@scalar/typebox": "^0.1.3", + "@scalar/types": "0.17.0", + "@scalar/use-codemirror": "0.14.14", + "@scalar/use-hooks": "0.4.9", + "@scalar/use-toasts": "0.10.4", + "@scalar/workspace-store": "0.56.0", + "@vueuse/core": "13.9.0", + "@vueuse/integrations": "13.9.0", + "focus-trap": "^7.8.0", + "fuse.js": "^7.1.0", + "js-base64": "^3.7.8", + "jsonc-parser": "3.3.1", + "nanoid": "^5.1.6", + "pretty-ms": "^9.3.0", + "radix-vue": "^1.9.17", + "set-cookie-parser": "3.1.0", + "vue": "^3.5.30", + "yaml": "^2.8.3", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-client/node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/@scalar/api-client/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "@shikijs/primitive": "4.0.2", - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" + "bin": { + "nanoid": "bin/nanoid.js" }, "engines": { - "node": ">=20" + "node": "^18 || >=20" } }, - "node_modules/@shikijs/engine-javascript": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", - "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "node_modules/@scalar/api-reference": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@scalar/api-reference/-/api-reference-1.64.0.tgz", + "integrity": "sha512-CFtFWX+X8XJvrSoa1OGPpTzf7p81sbL10fBSCiRO/nN3tKUc3IqEhL0EYzK+K3HThApTOEgtfSY/3bIS2mA6ew==", + "license": "MIT", + "dependencies": { + "@headlessui/vue": "1.7.23", + "@scalar/agent-chat": "0.12.23", + "@scalar/api-client": "3.14.0", + "@scalar/blocks": "0.1.9", + "@scalar/code-highlight": "0.4.3", + "@scalar/components": "0.27.10", + "@scalar/helpers": "0.9.2", + "@scalar/icons": "0.7.5", + "@scalar/oas-utils": "0.19.9", + "@scalar/schemas": "0.8.0", + "@scalar/sidebar": "0.9.34", + "@scalar/snippetz": "0.9.24", + "@scalar/themes": "0.17.2", + "@scalar/types": "0.17.0", + "@scalar/use-hooks": "0.4.9", + "@scalar/use-toasts": "0.10.4", + "@scalar/validation": "0.6.2", + "@scalar/workspace-store": "0.56.0", + "@unhead/vue": "^2.1.4", + "@vueuse/core": "13.9.0", + "fuse.js": "^7.1.0", + "microdiff": "^1.5.0", + "nanoid": "^5.1.6", + "vue": "^3.5.30", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-reference/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" + "bin": { + "nanoid": "bin/nanoid.js" }, "engines": { - "node": ">=20" + "node": "^18 || >=20" } }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "node_modules/@scalar/asyncapi-upgrader": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@scalar/asyncapi-upgrader/-/asyncapi-upgrader-0.1.4.tgz", + "integrity": "sha512-pyAoyuFVlUf+ZThiuHAShV4lyF9LlPab5AJrvoER8JBz00bHF1NWiGveYySRcNcvke5U5nWCr7NxtiMIokP8sA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", - "@shikijs/vscode-textmate": "^10.0.2" + "@scalar/helpers": "0.9.2" }, "engines": { - "node": ">=20" + "node": ">=22" } }, - "node_modules/@shikijs/langs": { - "version": "4.0.2", + "node_modules/@scalar/blocks": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@scalar/blocks/-/blocks-0.1.9.tgz", + "integrity": "sha512-D0Y5HnQ+VPf6Cj9lHZl93Ner8gxdQ85qRP7c/njCeB8cqVSxvB22iiY5gxKELLNvO6YVd6b4BFU3II0DQQPv0g==", + "license": "MIT", + "dependencies": { + "@scalar/components": "0.27.10", + "@scalar/helpers": "0.9.2", + "@scalar/icons": "0.7.5", + "@scalar/snippetz": "0.9.24", + "@scalar/themes": "0.17.2", + "@scalar/types": "0.17.0", + "@scalar/workspace-store": "0.56.0", + "@types/har-format": "^1.2.16", + "js-base64": "^3.7.8", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/code-highlight": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@scalar/code-highlight/-/code-highlight-0.4.3.tgz", + "integrity": "sha512-uueW22siajrJUtszH+k/PmABqau2MmJzajpEFTPapK7Zak167xcKUIjcMbFFFgW8SGKLMSVzLkuB/VNDbOHuOg==", + "license": "MIT", + "dependencies": { + "hast-util-to-text": "^4.0.2", + "highlight.js": "^11.11.1", + "lowlight": "^3.3.0", + "rehype-external-links": "^3.0.0", + "rehype-format": "^5.0.1", + "rehype-parse": "^9.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-stringify": "^11.0.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/components": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@scalar/components/-/components-0.27.10.tgz", + "integrity": "sha512-nCN7lksiktG8sPzqWNIbxehNNbbbcXsSGZCbnnpQtRveNkVuOuceCpn7EbbPG65hB+H0o0ZBPki3arPUwBUDwA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "0.2.10", + "@floating-ui/vue": "1.1.9", + "@headlessui/tailwindcss": "^0.2.2", + "@headlessui/vue": "1.7.23", + "@scalar/code-highlight": "0.4.3", + "@scalar/helpers": "0.9.2", + "@scalar/icons": "0.7.5", + "@scalar/themes": "0.17.2", + "@scalar/use-hooks": "0.4.9", + "@vueuse/core": "13.9.0", + "cva": "1.0.0-beta.4", + "radix-vue": "^1.9.17", + "vue": "^3.5.30", + "vue-component-type-helpers": "^3.2.6" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.2.tgz", + "integrity": "sha512-hjyMpMZjTBZQhyByZmz5oUgRKUQJO5V5AOiJxsVEGbUmgA7sJRQeTrXLB+BEwzaKS5nm2opJeNyMBYLFNK4hiQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/icons": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@scalar/icons/-/icons-0.7.5.tgz", + "integrity": "sha512-kWYkjmYlHzrZ+31d8L1uJpVeil6r1xNcyR+MewnxFslpMBCDIeo9udhYzg09v8s6nWKxwuPokhdQPLbKS+SSgg==", + "license": "MIT", + "dependencies": { + "@phosphor-icons/core": "^2.1.1", + "@types/node": "^24.1.0", + "chalk": "^5.6.2", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/json-magic": { + "version": "0.12.19", + "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.12.19.tgz", + "integrity": "sha512-1T4QoFYZ1nKt25xFeHtghAuZzaLq2X4CpCSLFXG0Fjcz6K2HIZqo+RtywfI0WD8RRRRgS34keo7X4Gv1BQUNoQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "pathe": "^2.0.3", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/oas-utils": { + "version": "0.19.9", + "resolved": "https://registry.npmjs.org/@scalar/oas-utils/-/oas-utils-0.19.9.tgz", + "integrity": "sha512-GnpsFGJTavHNX5qsMydQzAgpylcjv5eQK1Ee0TqiIf9Y8N79snsONepsmfS469/ld5SgYkY06DfgTzkuw/ANYQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "@scalar/themes": "0.17.2", + "@scalar/types": "0.17.0", + "@scalar/workspace-store": "0.56.0", + "flatted": "^3.4.0", + "vue": "^3.5.30", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.9.4.tgz", + "integrity": "sha512-eUSIjZEBLEF2i2pvcNdzXhzlKx6qt+hZsNklLmCyzPBoXWxHcQaEClgMFavXDRRvJDdi6LkyjqGUqqf0XgRgFg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-upgrader": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.12.tgz", + "integrity": "sha512-oj8O79xVDCHx759owTknwyBAYynaDQ8NGa5wIHduEUAFCob4YnsMEaFDpsconzogchH/whOGzg63BXHST8XPAw==", + "license": "MIT", + "dependencies": { + "@scalar/openapi-types": "0.9.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.8.0.tgz", + "integrity": "sha512-bwu/NCOghZI/cL6Ayti/Oeb5XEMRTncBsyQeHhkFwk4PZsR910me+kZPd5TAp6TqifMfDbAe3xyLSdlzU/KFLA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "@scalar/validation": "0.6.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/sidebar": { + "version": "0.9.34", + "resolved": "https://registry.npmjs.org/@scalar/sidebar/-/sidebar-0.9.34.tgz", + "integrity": "sha512-QFVicMkQhudDnBVA5/fFqmIp1nTKiSovSlueeSqgmr+cvd6oKb399PEU5MKuf5kFQTP5Wh3eQfVLwHBwjUeRZg==", + "license": "MIT", + "dependencies": { + "@scalar/components": "0.27.10", + "@scalar/helpers": "0.9.2", + "@scalar/icons": "0.7.5", + "@scalar/themes": "0.17.2", + "@scalar/use-hooks": "0.4.9", + "@scalar/workspace-store": "0.56.0", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/snippetz": { + "version": "0.9.24", + "resolved": "https://registry.npmjs.org/@scalar/snippetz/-/snippetz-0.9.24.tgz", + "integrity": "sha512-ukzC48sWv0cYGzdhGLpZYI9DsL818F0M70OHAQcxlE+kOT0WMrzWQqyt7t5WRWQYd6frK4qp32GcLaWb90cfkQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "@scalar/types": "0.17.0", + "js-base64": "^3.7.8", + "stringify-object": "^6.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/themes": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@scalar/themes/-/themes-0.17.2.tgz", + "integrity": "sha512-r/jgXyddfp7oq9o8UQB8+/c0R6rLRlD0ClfFCxivnxUKsuRnBuTs4xWF6DyZJ2M0D2rpUQiVgWeIVdg+T5FInA==", + "license": "MIT", + "dependencies": { + "nanoid": "^5.1.6" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/themes/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/typebox": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@scalar/typebox/-/typebox-0.1.3.tgz", + "integrity": "sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg==", + "license": "MIT" + }, + "node_modules/@scalar/types": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.17.0.tgz", + "integrity": "sha512-mj033MX0EFOEwfpO3ch7FGGnMbye1aLlnP6LmTC9Il2AlBCDL41rYPk67jF5ICAMsAES1RQ+8N2bcC0MJnupLQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/use-codemirror": { + "version": "0.14.14", + "resolved": "https://registry.npmjs.org/@scalar/use-codemirror/-/use-codemirror-0.14.14.tgz", + "integrity": "sha512-A7Exfctudg7d3DeQFSSrZYc6yhrEsLX3Iq6GJ7m7ruqgev9y8Hbx7g8yvZWYRQuMUdFtLH5euP7a1ZwM8OrHKA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.8", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.10.7", + "@codemirror/lint": "^6.8.4", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/common": "^1.2.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-css-color-picker": "^6.3.0", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/use-hooks": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@scalar/use-hooks/-/use-hooks-0.4.9.tgz", + "integrity": "sha512-RnflJ178BPdH7pKY9/YBcRlhyc7uJxtqG4hBbi8IPEM4YkpLHcMmxWKgHAgC3MBc21rpuKKBE+DTJOiYmU+UyA==", + "license": "MIT", + "dependencies": { + "@scalar/use-toasts": "0.10.4", + "@scalar/validation": "0.6.2", + "@vueuse/core": "13.9.0", + "cva": "1.0.0-beta.4", + "tailwind-merge": "3.5.0", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/use-toasts": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@scalar/use-toasts/-/use-toasts-0.10.4.tgz", + "integrity": "sha512-OHXkVfFKV0qx2WpKlzUpvIUkoA/PiloBcXe6soX2S/1z/D042QlKO8rNxkuC3Hlamvyvj8ru8XN17XNl+D0OPg==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.30", + "vue-sonner": "^1.3.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/validation": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.2.tgz", + "integrity": "sha512-Sc1TkcwGV6aVCO51AyKeaGiP8gpwAHxEtO5d3tZzPV+KsnlC/YokQxFxwBrbIXw73k9hmcExnJyGu3k5i6n6VA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/workspace-store": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@scalar/workspace-store/-/workspace-store-0.56.0.tgz", + "integrity": "sha512-Kt6I/eLvyzGrD409Fl5qlNhJSLNVYlJ2T+IgY/J++PAtqcKPmWePL5KKmXZhooXKKBBkzN7CXIRkjEKTKHXx5A==", + "license": "MIT", + "dependencies": { + "@scalar/asyncapi-upgrader": "0.1.4", + "@scalar/helpers": "0.9.2", + "@scalar/json-magic": "0.12.19", + "@scalar/openapi-upgrader": "0.2.12", + "@scalar/schemas": "0.8.0", + "@scalar/snippetz": "0.9.24", + "@scalar/typebox": "0.1.3", + "@scalar/types": "0.17.0", + "@scalar/validation": "0.6.2", + "js-base64": "^3.7.8", + "type-fest": "^5.3.1", + "vue": "^3.5.30", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", "license": "MIT", @@ -2457,9 +3430,17 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -2717,6 +3698,32 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.35", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.35.tgz", + "integrity": "sha512-lOfSPvgPdlaH6Qy+CyIc3XpycitaSQ9GECndGpTuDiu+uDA1am+90yWXwzDSd/20ZM196ggWJLS+Qb6WjVd/OA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2759,6 +3766,12 @@ "@types/estree": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -2798,18 +3811,58 @@ "@types/unist": "*" } }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@unhead/vue": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.17.tgz", + "integrity": "sha512-pnC8x9HLV3qQXdvWfylUEU25uhfCAy3ly9nmpQz84j9py818DRfU8jOsQ5wjdWtxyU1vX/fW2udfm4jtxUK8Bg==", + "license": "MIT", + "dependencies": { + "hookable": "^6.0.1", + "unhead": "2.1.17" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + }, + "peerDependencies": { + "vue": ">=3.5.18" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitest/expect": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", @@ -3031,6 +4084,248 @@ "dev": true, "license": "MIT" }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-13.9.0.tgz", + "integrity": "sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3052,6 +4347,24 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ai": { + "version": "6.0.33", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.33.tgz", + "integrity": "sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.13", + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.5", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -3141,6 +4454,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -3411,6 +4736,18 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3561,7 +4898,19 @@ "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", "license": "BlueOak-1.0.0", "engines": { - "node": ">= 18" + "node": ">= 18" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/convert-source-map": { @@ -3589,6 +4938,12 @@ "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/crossws": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", @@ -3672,6 +5027,32 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cva": { + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/cva/-/cva-1.0.0-beta.4.tgz", + "integrity": "sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + }, + "peerDependencies": { + "typescript": ">= 4.5.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4109,6 +5490,15 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4129,7 +5519,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-string-truncated-width": { @@ -4190,6 +5579,12 @@ } } }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "license": "ISC" + }, "node_modules/flattie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", @@ -4199,6 +5594,15 @@ "node": ">=8" } }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, "node_modules/fontace": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", @@ -4234,6 +5638,31 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fuse.js": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.5.0.tgz", + "integrity": "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4253,6 +5682,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -4265,6 +5706,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/guess-json-indent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/guess-json-indent/-/guess-json-indent-3.0.1.tgz", + "integrity": "sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -4282,6 +5732,39 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4320,6 +5803,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-is-element": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", @@ -4333,6 +5842,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -4346,6 +5872,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-raw": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", @@ -4371,6 +5914,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "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", @@ -4536,6 +6094,21 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", + "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4552,12 +6125,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, + "node_modules/identifier-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.1.0.tgz", + "integrity": "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==", + "license": "MIT", + "dependencies": { + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -4573,6 +6171,18 @@ "url": "https://github.com/sponsors/brc-dd" } }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -4642,6 +6252,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-identifier/-/is-identifier-1.1.0.tgz", + "integrity": "sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==", + "license": "MIT", + "dependencies": { + "identifier-regex": "^1.1.0", + "super-regex": "^1.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -4660,6 +6286,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -4672,6 +6310,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4696,6 +6346,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-base64": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.2.tgz", + "integrity": "sha512-6zayE8QlUdiweYI6cETD/XBSqFcoCUlufn/29PJR99r82x1yDnIprRca0YvAYpAW+ez0GuQkVBC6xG5QkD7OjA==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4726,6 +6382,12 @@ "node": ">=6" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5026,6 +6688,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5055,6 +6732,35 @@ "source-map-js": "^1.2.1" } }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-asynchronous/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -5385,6 +7091,12 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, + "node_modules/microdiff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/microdiff/-/microdiff-1.6.0.tgz", + "integrity": "sha512-w7JWt8Bno6I8h0rEqlxr4lNG4UbT1FVtWo42wWosIiaJ+rP3pXh7shCYDnyqBrYx36LJ1CZ64p8A62aVp0sJpQ==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -6145,9 +7857,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -6171,6 +7883,12 @@ "node": ">= 10" } }, + "node_modules/neverpanic": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/neverpanic/-/neverpanic-0.0.8.tgz", + "integrity": "sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw==", + "license": "MIT" + }, "node_modules/nlcst-to-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", @@ -6277,6 +7995,33 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event/node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", @@ -6369,6 +8114,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-numeric-range": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", @@ -6540,6 +8297,21 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -6559,6 +8331,140 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/radix-vue": { + "version": "1.9.17", + "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz", + "integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.7", + "@floating-ui/vue": "^1.1.0", + "@internationalized/date": "^3.5.4", + "@internationalized/number": "^3.5.3", + "@tanstack/vue-virtual": "^3.8.1", + "@vueuse/core": "^10.11.0", + "@vueuse/shared": "^10.11.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.4", + "fast-deep-equal": "^3.1.3", + "nanoid": "^5.0.7" + }, + "peerDependencies": { + "vue": ">= 3.2.0" + } + }, + "node_modules/radix-vue/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/radix-vue/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/radix-vue/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/radix-vue/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -6686,6 +8592,38 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-external-links": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", + "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-is-element": "^3.0.0", + "is-absolute-url": "^4.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -6751,6 +8689,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.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", @@ -6888,6 +8840,18 @@ "node": ">=0.10.0" } }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/retext": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", @@ -7011,6 +8975,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -7171,6 +9141,24 @@ "dev": true, "license": "MIT" }, + "node_modules/string-byte-length": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-byte-length/-/string-byte-length-3.0.1.tgz", + "integrity": "sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/string-byte-slice": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-byte-slice/-/string-byte-slice-3.0.1.tgz", + "integrity": "sha512-GWv2K4lYyd2+AhmKH3BV+OVx62xDX+99rSLfKpaqFiQU7uOMaUY1tDjdrRD4gsrCr9lTyjMgjna7tZcCOw+Smg==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7200,6 +9188,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stringify-object": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-6.0.0.tgz", + "integrity": "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-identifier": "^1.0.1", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7213,6 +9219,12 @@ "node": ">=8" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -7231,6 +9243,23 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -7268,6 +9297,43 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/swrv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz", + "integrity": "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==", + "license": "Apache-2.0", + "peerDependencies": { + "vue": ">=3.2.26 < 4" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -7287,6 +9353,21 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -7364,6 +9445,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/truncate-json": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/truncate-json/-/truncate-json-3.0.1.tgz", + "integrity": "sha512-QVsbr1WhGLq2F0oDyYbqtOXcf3gcnL8C9H5EX8bBwAr8ZWvWGJzukpPrDrWgJMrNtgDbo74BIjI4kJu3q2xQWw==", + "license": "MIT", + "dependencies": { + "guess-json-indent": "^3.0.1", + "string-byte-length": "^3.0.1", + "string-byte-slice": "^3.0.1" + }, + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/tsconfck": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", @@ -7388,8 +9483,22 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/typesafe-path": { "version": "0.2.2", @@ -7462,6 +9571,12 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -7471,6 +9586,18 @@ "pathe": "^2.0.3" } }, + "node_modules/unhead": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.17.tgz", + "integrity": "sha512-HLMKXOszRhAPBrr6VlqCeVeJq2kbC4kXwzGLEZvvojPLWNYTJw22xG7Bfwhsvs31+IBet3Wl8ADg9dwYdyphfQ==", + "license": "MIT", + "dependencies": { + "hookable": "^6.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -8310,6 +10437,45 @@ "dev": true, "license": "MIT" }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.9.tgz", + "integrity": "sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==", + "license": "MIT" + }, + "node_modules/vue-sonner": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-1.3.2.tgz", + "integrity": "sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A==", + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -8320,6 +10486,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/which-pm-runs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", @@ -8922,7 +11094,6 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "devOptional": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 0abc8de..d459bba 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@astrojs/preact": "^5.0.2", "@fontsource-variable/archivo": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", + "@scalar/api-reference": "^1.64.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "astro-i18n": "^2.2.4", diff --git a/src/components/docs/ApiReference.astro b/src/components/docs/ApiReference.astro new file mode 100644 index 0000000..d7a5efd --- /dev/null +++ b/src/components/docs/ApiReference.astro @@ -0,0 +1,336 @@ +--- +/* + * ApiReference — la referencia de API interactiva de /docs/api, servida con + * Scalar desde src/data/openapi.json en vez de escrita a mano en MDX. + * + * Es una página COMPLETA, no un componente dentro de Docs.astro: Scalar trae su + * propio layout (sidebar sincronizado, paneles pegajosos, grid a 100dvh) y + * meterlo dentro del layout de docs obliga a pelearse con él. Lo que se aporta + * es la barra superior de NaN encima, y a Scalar se le deja el resto. + * + * Ambos idiomas renderizan el MISMO spec: el contenido de una referencia de API + * se queda en inglés por convención, y lo que se traduce es el chrome y las + * etiquetas de UI de Scalar (su opción `locale`). El cambio de idioma va con + * enlaces normales, como el resto del sitio (Nav.astro), no con el intercambio + * en caliente que se montó en helmcode. + */ +import { switchLocalePath, type Locale } from '../../lib/i18n'; + +interface Props { + lang?: Locale; +} + +const { lang = 'en' } = Astro.props; +const pfx = lang === 'es' ? '/es' : ''; + +const t = { + en: { + docs: 'Docs', + guides: 'Guides', + ref: 'API Reference', + platform: 'Platform ↗', + site: 'nan.builders ↗', + title: 'API Reference — NaN Docs', + description: + 'Interactive reference for the NaN API: OpenAI-compatible endpoints for chat, embeddings, rerank, audio, images and web search.', + }, + es: { + docs: 'Docs', + guides: 'Guías', + ref: 'Referencia API', + platform: 'Plataforma ↗', + site: 'nan.builders ↗', + title: 'Referencia API — NaN Docs', + description: + 'Referencia interactiva de la API de NaN: endpoints compatibles con OpenAI para chat, embeddings, rerank, audio, imágenes y búsqueda web.', + }, +}[lang]; + +const SITE = 'https://nan.builders'; +const canonical = `${SITE}${Astro.url.pathname.replace(/\/+$/, '') || '/'}`; +const ogImage = `${SITE}/og/og-${lang}.png`; + +/* + * `connect-src` tiene que dejar salir a api.nan.builders o el "try it" no + * funciona: el navegador manda la petición desde esta página al dominio de la + * API. Va directo a propósito, sin el proxy en la nube de Scalar (`proxyUrl: + * ''` abajo) — mandar la key de un miembro a un tercero contradice el "zero + * logs" del producto. La API responde con CORS abierto, así que no hace falta + * intermediario. `worker-src blob:` es para el resaltado de sintaxis de Scalar. + */ +const CSP = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: https:", + "connect-src 'self' https://api.nan.builders", + "worker-src 'self' blob:", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {t.title} + + + + + +
+ + NaN + NaN + {t.docs} + + +
+ + +
+ + + + + + diff --git a/src/content/docs/agents.md b/src/content/docs/agents.md index 3faacf0..4e0fa7d 100644 --- a/src/content/docs/agents.md +++ b/src/content/docs/agents.md @@ -9,7 +9,7 @@ order: 5 NaN Cloud lets you deploy AI agents in your own **microVM**: a lightweight virtual machine with QEMU + KVM, its own kernel, its own filesystem, and full root access. Isolated from the host and from other members. The first available agent type is **Hermes**. > **Using an agent you host yourself?** -> If you run your own MCP-compatible agent elsewhere, you can plug our tools (such as web search) straight into it with the same API key via our remote [MCP server](/docs/api#mcp). +> If you run your own MCP-compatible agent elsewhere, you can plug our tools (such as web search) straight into it with the same API key via our remote [MCP server](/docs/api#tag/mcp). ## Architecture diff --git a/src/content/docs/api.mdx b/src/content/docs/api.mdx deleted file mode 100644 index 03dad00..0000000 --- a/src/content/docs/api.mdx +++ /dev/null @@ -1,1154 +0,0 @@ ---- -title: API -description: Public API endpoint reference. OpenAI-compatible. -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'; - -# API Reference. - -Our API is OpenAI-compatible: any client or SDK that accepts a `base URL` + `API key` works without changes. The base URL is `https://api.nan.builders/v1` and authentication is via `Bearer token`. To get your key, see [Getting Started](/docs/getting-started). - - - If you use the Helmcode enterprise service, remember that the API URL is api.helmcode.com. All other endpoints are identical. - - -## Endpoints - -List of available endpoints. Each links to its section with `request`, `response`, and a `curl` example. - - - -## Authentication - -All requests require the `Authorization: Bearer ` header. The key is personal and non-transferable — see [Getting Started](/docs/getting-started) to get yours. - -```bash -curl https://api.nan.builders/v1/models \ - -H "Authorization: Bearer sk-your-key-here" -``` - -

GET /v1/models

- -Returns the list of available models for your key. Published models: `deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4`, `qwen3-embedding`, `rerank`, `kokoro`, `whisper`, `flux-2-klein` (includes the `flux-2-klein` image model). `glm5.2` is served too, and is callable only with a key on the GLM 5.2 premium tier. - -### Request - -No body. Only the authentication header required. - -### Response - -```json -{ - "object": "list", - "data": [ - { - "id": "qwen3.6", - "object": "model", - "created": 1677610602, - "owned_by": "openai" - }, - { - "id": "gemma4", - "object": "model", - "created": 1677610602, - "owned_by": "openai" - } - ] -} -``` - -### Example - -```bash -curl https://api.nan.builders/v1/models \ - -H "Authorization: Bearer sk-your-key-here" -``` - -

POST /v1/chat/completions

- -The main chat endpoint. OpenAI Chat Completions compatible. Compatible models: `deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4`, and `glm5.2`. `glm5.2` needs a key on the GLM 5.2 premium tier; the other models are available to every inference member. - -reasoning_content in the message).', - }, - { - title: 'gemma4', - body: 'Chat, streaming, vision (image input), reasoning (opt-in).', - }, - { - title: 'glm5.2 · premium tier', - body: 'Chat, streaming, tool calling, reasoning trace, 500K token context. Coding and long-horizon agentic tasks. 3,000M token quota per member, reset when your billing period starts, and 400M tokens per rolling 4h window. Requires a key on the GLM 5.2 premium tier.', - }, - ]} -/> - -### Request - -| Field | Type | Description | -|---|---|---| -| `model` | string · required | `deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4` or `glm5.2` (premium tier). | -| `messages` | array · required | List of messages `{ role, content }`. `content` can be a string or an array of parts `[{type:"text",text}, {type:"image_url",image_url:{url}}]` for multimodal input. | -| `max_tokens` | integer · optional | Maximum tokens to generate. | -| `stream` | boolean · optional | Default `false`. If `true`, the response arrives as SSE. | -| `tools` | array · optional | Standard OpenAI function calling: `{type:"function",function:{name,description,parameters}}`. Validated only with `qwen3.6`. | -| `tool_choice` | string \| object · optional | Controls which tool the model can invoke. Standard OpenAI. | -| `temperature` | number · optional | Default `0.6`. | -| `top_p` | number · optional | Default `0.95`. | - -### Response - -Non-streaming response. `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 - } -} -``` - -The `reasoning_content` field is included only when using `qwen3.6`. It is optional to ignore it. - -### Example - -```bash -curl https://api.nan.builders/v1/chat/completions \ - -H "Authorization: Bearer sk-your-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3.6", - "messages": [{"role": "user", "content": "Hola"}], - "max_tokens": 200 - }' -``` - -### Streaming - -With `stream: true`, the response is delivered as Server-Sent Events. Each chunk is `data: {...}\n\n` with the delta in `choices[0].delta.content`. The stream ends with `data: [DONE]`. - -```bash -curl https://api.nan.builders/v1/chat/completions \ - -N \ - -H "Authorization: Bearer sk-your-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3.6", - "messages": [{"role": "user", "content": "Cuéntame un chiste corto"}], - "stream": true - }' -``` - -### Tool calling - -`qwen3.6` supports standard OpenAI function calling. When the model decides to invoke a tool, the response includes `choices[0].message.tool_calls` with `{id, type:"function", function:{name, arguments}}` and `finish_reason: "tool_calls"`. - -```bash -curl https://api.nan.builders/v1/chat/completions \ - -H "Authorization: Bearer sk-your-key-here" \ - -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": "Gets the current weather for a city", - "parameters": { - "type": "object", - "properties": { - "city": {"type": "string"} - }, - "required": ["city"] - } - } - } - ] - }' -``` - -### Vision - -`mimo-v2.5`, `qwen3.6` and `gemma4` accept multimodal input. The `content` field of the message changes from string to an array of parts of type `text` and/or `image_url`. `mimo-v2.5` also accepts `input_audio` as part of `content`. - -```bash -curl https://api.nan.builders/v1/chat/completions \ - -H "Authorization: Bearer sk-your-key-here" \ - -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 - -Chat models accept the standard OpenAI `response_format` field to force valid JSON responses. We support both modes: - -- **json_object**: Garantiza que la respuesta sea JSON sintácticamente válido. No impone estructura. -- **json_schema**: Restricts output to a specific JSON Schema. With `strict: true` the model cannot emit fields outside the schema. - -Funciona en `qwen3.6` y `gemma4`. - -**json_object:** - -```bash -curl https://api.nan.builders/v1/chat/completions \ - -H "Authorization: Bearer sk-your-key-here" \ - -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-your-key-here" \ - -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-your-key-here", - 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 - -Every chat LLM generates reasoning and returns it in `choices[0].message.reasoning_content`. The control mechanism varies by model: - -| Model | 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` | razonamiento activo · sin parámetro publicado para controlarlo | - -#### enable_thinking (qwen3.6, gemma4) - -Binary toggle. The field goes in the request body as `chat_template_kwargs.enable_thinking`: - -```bash -curl https://api.nan.builders/v1/chat/completions \ - -H "Authorization: Bearer sk-your-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gemma4", - "messages": [{"role": "user", "content": "Qué es 2+2?"}], - "chat_template_kwargs": { "enable_thinking": true } - }' -``` - -To disable it on `qwen3.6` pass `{ "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-your-key-here", - 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) - -Standard OpenAI parameter. Accepts `low`, `medium`, or `high` and goes as a top-level body field — not inside `extra_body`. If not provided, defaults to `medium`. - -```bash -curl https://api.nan.builders/v1/chat/completions \ - -H "Authorization: Bearer sk-your-key-here" \ - -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-your-key-here", - 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 de tokens. - -#### mimo-v2.5 - -MiMo V2.5 always reasons and emits `reasoning_content` in every response. Currently upstream Xiaomi ignores both `reasoning_effort` and `enable_thinking`, so the reasoning level is not configurable via the API. If you need to control it, use `deepseek-v4-flash`. - -

POST /v1/completions

- -Endpoint legacy de OpenAI para text completion. Compatible model: `qwen3.6`. - -### Request - -| Field | Type | Description | -|---|---|---| -| `model` | string · required | `qwen3.6`. | -| `prompt` | string · required | The prompt to complete. | -| `max_tokens` | integer · optional | Maximum tokens to generate. | -| `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 - } -} -``` - -### Example - -```bash -curl https://api.nan.builders/v1/completions \ - -H "Authorization: Bearer sk-your-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3.6", - "prompt": "The capital of France is", - "max_tokens": 10 - }' -``` - -### Notes - -Endpoint legacy de OpenAI. Para conversaciones, usa [/v1/chat/completions](#chat-completions). - -

POST /v1/embeddings

- -Genera embeddings vectoriales. Compatible model: `qwen3-embedding`. Vectores de **4096 dimensiones**. - -### Request - -| Field | Type | Description | -|---|---|---| -| `model` | string · required | `qwen3-embedding`. | -| `input` | string \| array · required | Single text or array of strings to embed. | -| `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 - } -} -``` - -### Example - -```bash -curl https://api.nan.builders/v1/embeddings \ - -H "Authorization: Bearer sk-your-key-here" \ - -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. Compatible model: `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 - -| Field | Type | Description | -|---|---|---| -| `model` | string · required | `rerank`. | -| `query` | string · required | Query against which each document's relevance is measured. | -| `documents` | array · required | Array de strings a reordenar. La respuesta los returns 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 returns 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. - -### Example - -```bash -curl https://api.nan.builders/v1/rerank \ - -H "Authorization: Bearer sk-your-key-here" \ - -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

- -Synthesizes audio from text (text-to-speech). Compatible model: `kokoro`. - -### Request - -| Field | Type | Description | -|---|---|---| -| `model` | string · required | `kokoro`. | -| `input` | string · required | Text to synthesize. | -| `voice` | string · required | Voice to use. Some options: `af_heart` (English female), `ef_dora` (Spanish female), `em_alex` (Spanish male). [See full list](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). | -| `response_format` | string · optional | Format of the returned audio. Validated: `mp3` (default), `wav`, `flac`, `aac`, `pcm`, `opus`. | -| `speed` | number · optional | Default `1.0`. | - -### Response - -Binary audio file in the requested format (without JSON wrapper). - -### Example - -```bash -curl https://api.nan.builders/v1/audio/speech \ - -H "Authorization: Bearer sk-your-key-here" \ - -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

- -Transcribes audio to text (speech-to-text). Compatible model: `whisper`. The request is `multipart/form-data`. - -### Request - -| Field | Type | Description | -|---|---|---| -| `file` | file · required | Audio file to transcribe. | -| `model` | string · required | `whisper`. | -| `language` | string · optional | ISO-639-1 code (ej. `es`, `en`). If not provided, detected automatically. | -| `response_format` | string · optional | Validated: `json` (default) y `verbose_json`. Other values work but return content wrapped in JSON; we recommend only these two. | -| `timestamp_granularities[]` | string · optional | Only with `verbose_json`. Valores: `word` (timestamps por palabra) o `segment` (default). | -| `temperature` | number · optional | Sampling temperature. | - -### Response - -Example with `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 -} -``` - -If you pass `timestamp_granularities[]=word`, el campo `words` is populated with `[{word, start, end, probability}]`. - -### Example - -```bash -curl https://api.nan.builders/v1/audio/transcriptions \ - -H "Authorization: Bearer sk-your-key-here" \ - -F "model=whisper" \ - -F "file=@grabacion.mp3" \ - -F "language=es" \ - -F "response_format=verbose_json" -``` - -### Limitations - - 2 min pueden devolver timeout 524', - body: 'Recomendamos dividir en segmentos de ≤ 2 min.', - }, - { - title: 'Formats recomendados', - body: 'OGG/Opus y MP3 — mejor compresión, misma calidad de transcripción.', - }, - ]} -/> - -

POST /v1/responses

- -OpenAI-style Responses endpoint. Modelos compatibles: `qwen3.6` y `gemma4`. - -### Request - -| Field | Type | Description | -|---|---|---| -| `model` | string · required | `qwen3.6` o `gemma4`. | -| `input` | string \| array · required | Single text or array of messages in OpenAI Responses format. | -| `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 | System instructions. | - -### Response - -The array `output` can contain blocks of type `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 - } -} -``` - -### Example - -```bash -curl https://api.nan.builders/v1/responses \ - -H "Authorization: Bearer sk-your-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3.6", - "input": "Hola, ¿cómo estás?" - }' -``` - -### Notes - -Streaming on this endpoint currently delivers a single event `response.completed` at the end, not incremental chunks. For token-by-token streaming use [/v1/chat/completions](#chat-completions) con `stream: true`. - -

POST /v1/images/generations

- -Generates images from text (text-to-image). Compatible with OpenAI's Images API. Compatible model: `flux-2-klein` (only model available today; the endpoint is designed to add more). The body is JSON. - -### Request - -| Field | Type | Description | -|---|---|---| -| `prompt` | string · required | Textual description of the image to generate. | -| `model` | string · optional | Default `flux-2-klein` (único modelo disponible). An unknown model returns `404` (`model_not_found`). | -| `n` | integer · optional | Number of images to generate, between `1` y `4`. Default `1`. A value greater than `4` returns `400`. | -| `size` | string · optional | Format `"ANCHOxALTO"` con both sides divisible by 16, each between 256 and 1536, and aspect ratio between 1:3 and 3:1. Standard values like `1024x1024`, `1536x1024` o `1024x1536` work. `"auto"` or omitted → `1024x1024`. | -| `response_format` | string · optional | `"url"` (default) o `"b64_json"`. Con `url` a temporary R2 link is returned valid ~60 minutos (same contract as OpenAI). Con `b64_json` the image bytes are returned inline as base64. | - - - For OpenAI SDK compatibility, the following are accepted quality, style, background, moderation, output_format, output_compression y user, pero are ignored — Flux does not act on them. Además, stream: true is not supported y returns 400. - - -### Parámetros adicionales (extensiones NaN) - -These parameters **no** forman parte de la Images API de OpenAI. Con el SDK de `openai` are passed via `extra_body`. - -| Field | Type | Description | -|---|---|---| -| `seed` | integer · optional | Base seed for reproducibility. Each variant (when `n > 1`) starts from an offset on this value. | -| `guidance` | number · optional | FLUX guidance scale. | - -### Response - -Same wrapper as OpenAI's Images API. `created` is the Unix timestamp in seconds. - -```json -{ - "created": 1778258200, - "data": [ - { "url": "https://...r2.../image.png" } - ] -} -``` - -Con `response_format=b64_json`, cada elemento de `data` es `{ "b64_json": "..." }` instead of `{ "url": "..." }`. - -### Example - -```bash -curl https://api.nan.builders/v1/images/generations \ - -H "Authorization: Bearer sk-your-key-here" \ - -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-your-key-here" \ - -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 (the extensions `seed` y `guidance` van en `extra_body`): - -```python -from openai import OpenAI - -client = OpenAI( - api_key="sk-your-key-here", - 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 tiene sus propios límites, independientes de los [rate limits](#rate-limits) de los endpoints de modelos: 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 returns `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 returns `429` (`insufficient_quota`). Esta cuota es independiente de la cuota mensual de tokens del chat, que depende del modelo. | -| Tier | `inference` | Requiere membresía de tier inference. Las keys de tier community reciben `403` (`tier_restricted`). | - -

POST /v1/images/edits

- -Generates an image from one or more reference images (image-to-image). Compatible with OpenAI's Images API. Compatible model: `flux-2-klein`. The request is `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 - -| Field | Type | Description | -|---|---|---| -| `image` / `image[]` | file · required | One or more reference images (up to 4; extras are discarded). PNG, JPEG, or WebP, each < 25 MB. | -| `prompt` | string · required | Description of the edit or transformation to apply. | -| `model`, `n`, `size`, `response_format` | optional | Same behavior as in [/v1/images/generations](#images-generations). Las extensiones `seed` y `guidance` are also accepted (as form fields). | - - - El parámetro mask is not supported y returns 400 — Flux Klein does not do inpainting. - - -### Response - -Same wrapper as [/v1/images/generations](#images-generations): `{ "created": ..., "data": [{ "url": "..." }] }` (o elementos `{ "b64_json": "..." }` con `response_format=b64_json`). - -### Example - -```bash -curl https://api.nan.builders/v1/images/edits \ - -H "Authorization: Bearer sk-your-key-here" \ - -F "model=flux-2-klein" \ - -F "image[]=@ref.png" \ - -F "prompt=Convierte la escena en invierno con nieve" \ - -F "size=1024x1024" -``` - - - -An authenticated web-search tool for your agents. You send a query and we return ranked results (title, URL, snippet, and optionally the page's readable content) through our API — your key never talks to a third-party search provider directly. Results come from a hybrid of an external search API (primary) and an internal fallback; you don't need to know which served a given request. - -Designed to be wired as an agent tool: give the model the [function schema below](#web-search-tool), and when it emits a `web_search` tool call, invoke this endpoint with the arguments and feed the JSON response back as the tool result. - -### Request - -The body is JSON. - -| Field | Type | Description | -|---|---|---| -| `query` | string · required | The search query. | -| `count` | integer · optional | Number of results to return, `1`–`20` (clamped). Default `5`. | -| `freshness` | string · optional | Recency filter: `pd` (past day), `pw` (past week), `pm` (past month), `py` (past year), or a `YYYY-MM-DDtoYYYY-MM-DD` date range. Omit for no time filter. | -| `fetch_content` | boolean · optional | When `true`, also fetch and include the readable text of the top results. Adds latency. Default `false` returns snippets only. | - - - When fetch_content is true, the server fetches page content only for the URLs the search engine returned, through a hardened anti-SSRF fetcher — you cannot pass an arbitrary URL to fetch. - - -### Response - -```json -{ - "results": [ - { - "title": "Go 1.23 is released", - "url": "https://go.dev/blog/go1.23", - "snippet": "The latest Go release ...", - "content": "Full readable page text (only present when fetch_content=true)", - "source": "primary" - } - ], - "cached": false -} -``` - -| Field | Type | Description | -|---|---|---| -| `results[].title` | string | Result title (plain text). | -| `results[].url` | string | Result URL. | -| `results[].snippet` | string | Short excerpt. | -| `results[].content` | string | Readable page text — present only when `fetch_content=true` and the fetch succeeded. | -| `results[].source` | string | Which tier served the result: `primary` or `fallback`. | -| `cached` | boolean | `true` when the results came from the short-lived (~15 min) exact-query cache. Cached hits still count toward your rate limit and daily quota. | - -### Example - -```bash -curl https://api.nan.builders/v1/search \ - -H "Authorization: Bearer sk-your-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "query": "latest go release", - "count": 5, - "freshness": "pw" - }' -``` - -

Wire it as an agent tool

- -Drop this into your model call's `tools` array (OpenAI-style function calling). When the model emits a `web_search` tool call, invoke `POST /v1/search` with the arguments and return the JSON response as the tool result. - -```json -{ - "type": "function", - "function": { - "name": "web_search", - "description": "Search the public web and return relevant results (title, URL, snippet, and optional page content). Use it when the answer may depend on current events, recent developments, prices, release/version numbers, or facts you are not confident are up to date.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The web search query." - }, - "count": { - "type": "integer", - "description": "Number of results to return (1-20).", - "minimum": 1, - "maximum": 20, - "default": 5 - }, - "freshness": { - "type": "string", - "description": "Restrict results by recency: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or a 'YYYY-MM-DDtoYYYY-MM-DD' date range. Omit for no time filter." - }, - "fetch_content": { - "type": "boolean", - "description": "When true, also fetch and include the readable text of the top results (slower). Default false returns snippets only.", - "default": false - } - }, - "required": ["query"] - } - } -} -``` - -### Rate limits and quota - -Web search has its own limits, separate from the standard [rate limits](#rate-limits) of the model endpoints: searching does not consume your chat rpm budget, and vice versa. These limits are enforced per API key: - -| Limit | Value | Description | -|---|---|---| -| Rate limit | `20 req/min` | 20 requests per minute. Exceeding it returns `429` (`rate_limit_exceeded`); respect the `Retry-After` header. | -| Concurrency | `3` | Up to 3 concurrent searches. Exceeding it returns `429` (`rate_limit_exceeded`). | -| Daily quota | `500 / day` | 500 searches per day per API key. Exceeding it returns `429` (`insufficient_quota`). | - -### Search errors - -In addition to the [common errors](#errors), this endpoint may return: - -| Status | `code` | Description | -|---|---|---| -| 401 | `invalid_api_key` | The `Authorization` header is missing or the key is invalid. | -| 429 | `rate_limit_exceeded` | Per-minute or concurrency limit reached. Respect the `Retry-After` header. | -| 429 | `insufficient_quota` | Daily search quota exhausted. | -| 503 | `search_unavailable` | Search is temporarily unavailable; retry shortly. | - -

MCP server

- -We expose a remote [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server so you can use our tools directly inside any MCP-compatible agent or client, authenticated with the **same** `sk-` API key as the REST API. The endpoint is `https://api.nan.builders/mcp` (Helmcode enterprise uses the same shape on `api.helmcode.com/mcp`). - -Today the server exposes a single tool, [`web_search`](#web-search) (same capability and arguments as `POST /v1/search`), and it is a growing registry — more tools will be added over time and appear automatically under `tools/list`. - -| Property | Value | -|---|---| -| Endpoint | `https://api.nan.builders/mcp` | -| Transport | Streamable HTTP (remote), stateless | -| Protocol | JSON-RPC 2.0 — methods: `initialize`, `tools/list`, `tools/call`, `ping` | -| Auth | `Authorization: Bearer ` — the same `sk-` key as the REST API. Unauthenticated requests get `401`. | -| Tools | `web_search` (more coming). Arguments match [`POST /v1/search`](#web-search): `query`, `count` (1–20, default 5), `freshness`, `fetch_content` (default false). | - - - MCP calls share the same per-key rate limit, daily quota, and concurrency as the equivalent REST endpoint — there is no separate budget. A web_search tool call over MCP counts exactly like a POST /v1/search request. - - -### Connect an MCP client - -Most native remote MCP clients accept a `url` plus `headers`. Point them at the endpoint and pass your key in the `Authorization` header: - -```json -{ - "mcpServers": { - "nan": { - "url": "https://api.nan.builders/mcp", - "headers": { - "Authorization": "Bearer sk-your-key-here" - } - } - } -} -``` - -Clients that only speak stdio (some desktop apps) can bridge to the remote server with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): - -```json -{ - "mcpServers": { - "nan": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://api.nan.builders/mcp", - "--header", - "Authorization:Bearer sk-your-key-here" - ] - } - } -} -``` - - - Many clients prefix each tool with the server name, so a server entry named nan surfaces the tool as nan_web_search. A verbose name like nan-web-search would produce a duplicated-looking nan_web_search_web_search — name the entry nan. - - -### Raw JSON-RPC - -You can also drive the server directly. A `tools/call` for `web_search`: - -```bash -curl https://api.nan.builders/mcp \ - -H "Authorization: Bearer sk-your-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "web_search", - "arguments": { "query": "kubernetes 1.34 release", "count": 5 } - } - }' -``` - -Use `tools/list` to discover the current tool set and each tool's input schema. - -## Errors - -Errors follow the standard OpenAI format: HTTP non-2xx status with a JSON body describing the problem. - -```json -{ - "error": { - "message": "...", - "type": null, - "param": null, - "code": "..." - } -} -``` - -| Código | Descripción | -|---|---| -| 400 | Parámetro inválido — the body includes `param` with the field that failed (e.g. `prompt`, `n`, `size`, `stream`, `mask` o `image` en los endpoints de imágenes). El filtro de seguridad returns `content_policy_violation`. | -| 401 | `Authorization` header invalid or missing (`invalid_api_key`). | -| 402 | Token allowance for the billing period exhausted on a model that carries one (`monthly_cap_reached`), such as `glm5.2`. Not retryable: the counter goes back to zero when your billing period starts. | -| 403 | Your tier does not have access to the endpoint (`tier_restricted`). Image generation requires inference membership. | -| 404 | Model does not exist (campo `model`, `model_not_found`). | -| 429 | Rate limit exceeded — `rpm_limit` o `max_parallel_requests` (`rate_limit_exceeded`), the rolling 4h token budget of `glm5.2` (the body says how much frees up and when), or monthly quota exhausted (`quota_exceeded` / `insufficient_quota`, como la de 100 requests de imágenes). | -| 500 | Internal error (includes upstream model errors). | -| 524 | Timeout (typical with large audios on [/v1/audio/transcriptions](#audio-transcriptions)). | - -## Rate limits - - diff --git a/src/data/openapi.json b/src/data/openapi.json new file mode 100644 index 0000000..e396acd --- /dev/null +++ b/src/data/openapi.json @@ -0,0 +1,2539 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "NaN API", + "version": "1.0.0", + "description": "Open models on a shared EU inference cluster. Zero logs.\n\nThe NaN API is OpenAI-compatible: predictable, resource-oriented URLs, JSON request and response bodies, and standard HTTP verbs and status codes. Point any OpenAI SDK at our base URL and your existing code keeps working. Change the base URL and the API key — that's it.\n\nOne schema across every model, so you only learn the API once. Change the `model` field to switch models; everything else stays the same.\n\n- Base URL: `https://api.nan.builders/v1`\n- OpenAPI spec: this document. Import it into Postman, Insomnia, or your own tooling.\n\nIf you use the [Helmcode](https://helmcode.com) enterprise service, the base URL is `https://api.helmcode.com/v1` instead. Every other endpoint is identical.\n\n## Authentication\n\nEvery request authenticates with an API key, sent as a Bearer token:\n\n```\nAuthorization: Bearer $NAN_API_KEY\n```\n\nYou must be a NaN community member. Generate your key from user settings, under \"API Keys\", on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. Keep it secret: never embed one in client-side code or commit it to source control. Requests must go over HTTPS; calls over plain HTTP fail.\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"qwen3.6\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. Only `/chat/completions` streams incrementally — `/responses` currently emits a single terminal event.\n\n## Rate limits\n\nLimits apply per API key — RPM and concurrency — not on total token volume:\n\n| Limit | Value |\n| --- | --- |\n| Requests per minute | 60 |\n| Concurrent requests | 5 |\n| Tokens per minute (`deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4`) | 1.5M |\n| Requests per minute (`rerank`) | 1,000 |\n\n`glm5.2` is not gated by a per-minute rate but by a rolling window plus an allowance per billing period: 400M tokens per rolling 4 hours and a 3,000M-token allowance that returns to zero when your billing period starts. The window is rolling, not a daily reset.\n\nWeb search runs on its own budget, separate from the model endpoints: 20 requests per minute, 3 concurrent, and 500 searches per day per key. Image endpoints have their own too: 20 requests per minute and 100 requests per month. Exceed any limit and you get a `429`.\n\n## Errors\n\nNaN uses conventional HTTP status codes: `2xx` on success, `4xx` for a problem with the request — a missing parameter, an invalid key, an unavailable model — and `5xx` for a server-side error. Every error returns a JSON body in the OpenAI shape:\n\n```json\n{\n \"error\": {\n \"message\": \"The model 'foo' does not exist.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"model\",\n \"code\": \"model_not_found\"\n }\n}\n```\n\n`message` is human-readable, `param` names the offending field when applicable, and `code` is a short machine-readable string you can branch on.\n\n| Status | Meaning | `code` |\n| --- | --- | --- |\n| `400` | Invalid or malformed parameter (`param` says which); or content blocked by the safety filter. | `invalid_request_error` · `content_policy_violation` |\n| `401` | Missing or invalid API key. | `invalid_api_key` |\n| `402` | The token allowance for the billing period is spent on a model that carries one, such as `glm5.2`. Not retryable: the counter returns to zero when your billing period starts. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint or model. Image generation requires inference membership. | `tier_restricted` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.2`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | — |\n| `503` | Web search is temporarily unavailable; retry shortly. | `search_unavailable` |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | — |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly: they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either — it clears when your billing period starts.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, reasoning | Streaming, tool calling, reasoning, 1M-token context. 500M tokens/month per member |\n| `mimo-v2.5` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision | Streaming, vision, reasoning (opt-in) |\n| `glm5.2` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, 500K-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n\n`glm5.2` is served only to keys on the GLM 5.2 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints — we add capabilities without breaking existing fields.", + "contact": { + "name": "NaN", + "url": "https://nan.builders" + }, + "termsOfService": "https://nan.builders/terms" + }, + "servers": [ + { + "url": "https://api.nan.builders/v1", + "description": "Production (NaN community cluster)" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Models", + "description": "List available models." + }, + { + "name": "Chat", + "description": "Chat completions, the main conversational endpoint." + }, + { + "name": "Completions", + "description": "Legacy text completions." + }, + { + "name": "Embeddings", + "description": "Vector embeddings." + }, + { + "name": "Rerank", + "description": "Reorder documents by relevance (RAG)." + }, + { + "name": "Audio", + "description": "Text-to-speech and speech-to-text." + }, + { + "name": "Responses", + "description": "OpenAI-style Responses endpoint." + }, + { + "name": "Images", + "description": "Text-to-image and image-to-image." + }, + { + "name": "Search", + "description": "Authenticated web search for agents." + }, + { + "name": "MCP", + "description": "Remote MCP server for agents and MCP clients." + } + ], + "paths": { + "/models": { + "get": { + "operationId": "listModels", + "tags": [ + "Models" + ], + "summary": "List models", + "description": "Returns a list of the models available to your API key, most-capable first. Each entry is a [Model](#tag/Models) object. Use an `id` as the `model` field in your requests.", + "responses": { + "200": { + "description": "A list of models.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ], + "description": "Always `list`." + }, + "data": { + "type": "array", + "description": "The available models.", + "items": { + "$ref": "#/components/schemas/Model" + } + } + } + }, + "example": { + "object": "list", + "data": [ + { + "id": "qwen3.6", + "object": "model", + "created": 1677610602, + "owned_by": "openai" + }, + { + "id": "deepseek-v4-flash", + "object": "model", + "created": 1677610602, + "owned_by": "openai" + } + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/models \\\n -H \"Authorization: Bearer $NAN_API_KEY\"" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nfor m in client.models.list().data:\n print(m.id)" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nconst models = await client.models.list();\nfor (const m of models.data) console.log(m.id);" + } + ] + } + }, + "/chat/completions": { + "post": { + "operationId": "createChatCompletion", + "tags": [ + "Chat" + ], + "summary": "Create chat completion", + "description": "Creates a model response for the given chat conversation. Compatible with OpenAI's Chat Completions API.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "model", + "messages" + ], + "properties": { + "model": { + "type": "string", + "description": "Model id. See [List models](#tag/Models) for what your key can use. Chat models are `deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4` and `glm5.2`.\n\n`glm5.2` requires a key on the GLM 5.2 premium tier — other keys get `403` `tier_restricted`. The rest are available to every inference member.", + "example": "qwen3.6" + }, + "messages": { + "type": "array", + "minItems": 1, + "description": "The conversation so far, oldest first. `content` is a string, or an array of parts (`text` + `image_url`) for vision input on `qwen3.6` and `gemma4`.", + "items": { + "$ref": "#/components/schemas/Message" + }, + "example": [ + { + "role": "user", + "content": "Hola" + } + ] + }, + "max_tokens": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of tokens to generate.", + "example": 512 + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "default": 0.6, + "description": "Sampling temperature. Higher is more random, lower more deterministic.", + "example": 0.6 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.95, + "description": "Nucleus sampling: only consider the top `top_p` probability mass.", + "example": 0.95 + }, + "stream": { + "type": "boolean", + "default": false, + "description": "When `true`, the reply arrives as Server-Sent Events: each `data:` line is a `chat.completion.chunk` with new text in `choices[0].delta.content`, ending with `data: [DONE]`.", + "example": false + }, + "tools": { + "type": "array", + "description": "Functions the model may call (OpenAI function schema). When it calls one, the reply carries `choices[0].message.tool_calls` and `finish_reason: \"tool_calls\"`. Validated on `qwen3.6`.", + "items": { + "$ref": "#/components/schemas/Tool" + } + }, + "tool_choice": { + "$ref": "#/components/schemas/ToolChoice" + }, + "response_format": { + "$ref": "#/components/schemas/ResponseFormat" + }, + "reasoning_effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "default": "medium", + "description": "Reasoning depth (`deepseek-v4-flash` only). The trace comes back in `message.reasoning_content`. More effort helps on hard problems, at the cost of latency and tokens.", + "example": "medium" + }, + "chat_template_kwargs": { + "type": "object", + "properties": { + "enable_thinking": { + "type": "boolean" + } + }, + "description": "Reasoning toggle for `qwen3.6`/`gemma4`: `{ \"enable_thinking\": boolean }`. In OpenAI SDKs, pass it inside `extra_body`." + } + } + }, + "examples": { + "basic": { + "summary": "Basic", + "value": { + "model": "qwen3.6", + "messages": [ + { + "role": "user", + "content": "Hola" + } + ], + "max_tokens": 200 + } + }, + "streaming": { + "summary": "Streaming (SSE)", + "value": { + "model": "qwen3.6", + "messages": [ + { + "role": "user", + "content": "Cuéntame un chiste corto" + } + ], + "stream": true + } + }, + "vision": { + "summary": "Vision (multimodal input)", + "value": { + "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" + } + } + ] + } + ] + } + }, + "json_schema": { + "summary": "Structured output (json_schema, strict)", + "value": { + "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 + } + } + } + } + }, + "reasoning_effort": { + "summary": "Reasoning effort (deepseek-v4-flash)", + "value": { + "model": "deepseek-v4-flash", + "messages": [ + { + "role": "user", + "content": "Resuelve paso a paso: 3x + 7 = 22" + } + ], + "reasoning_effort": "high" + } + }, + "premium_tier": { + "summary": "Premium tier model (glm5.2)", + "description": "`glm5.2` is served only to keys on the GLM 5.2 premium tier. Same request shape as any other model — only `model` changes. A key without the tier gets `403` `tier_restricted`.", + "value": { + "model": "glm5.2", + "messages": [ + { + "role": "user", + "content": "Refactor this function and explain the trade-offs." + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The chat completion. When `stream: false` the body is a single `chat.completion` (`application/json`). When `stream: true` the body is a `text/event-stream` of `chat.completion.chunk` events ending with `data: [DONE]`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatCompletion" + }, + "example": { + "id": "chatcmpl-...", + "created": 1778258163, + "model": "qwen3.6", + "object": "chat.completion", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "role": "assistant", + "content": "¡Hola! ¿En qué puedo ayudarte?", + "reasoning_content": "..." + } + } + ], + "usage": { + "completion_tokens": 20, + "prompt_tokens": 17, + "total_tokens": 37 + } + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ChatCompletionChunk" + }, + "example": "data: {\"id\":\"chatcmpl-...\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"¡Hola\"}}]}\n\ndata: {\"id\":\"chatcmpl-...\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/chat/completions \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"model\": \"qwen3.6\",\n \"messages\": [{\"role\": \"user\", \"content\": \"Hola\"}]\n }'" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"qwen3.6\",\n messages=[{\"role\": \"user\", \"content\": \"Hola\"}],\n)\nprint(resp.choices[0].message.content)" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nconst resp = await client.chat.completions.create({\n model: \"qwen3.6\",\n messages: [{ role: \"user\", content: \"Hola\" }],\n});\nconsole.log(resp.choices[0].message.content);" + } + ] + } + }, + "/completions": { + "post": { + "operationId": "createCompletion", + "tags": [ + "Completions" + ], + "summary": "Create completion", + "description": "Creates a completion for the provided prompt. This is a legacy endpoint; for conversations use [Create chat completion](#tag/Chat) instead. Model: `qwen3.6`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "model", + "prompt" + ], + "properties": { + "model": { + "type": "string", + "enum": [ + "qwen3.6" + ], + "description": "The model to use. Currently `qwen3.6`.", + "example": "qwen3.6" + }, + "prompt": { + "type": "string", + "description": "The prompt to complete.", + "example": "The capital of France is" + }, + "max_tokens": { + "type": "integer", + "minimum": 1, + "description": "The maximum number of tokens to generate.", + "example": 16 + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "default": 0.6, + "description": "Sampling temperature. Higher is more random." + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.95, + "description": "Nucleus sampling." + }, + "stream": { + "type": "boolean", + "default": false, + "description": "Stream partial completions as Server-Sent Events." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The completion.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Completion" + }, + "example": { + "id": "cmpl-...", + "object": "text_completion", + "created": 1778258166, + "model": "qwen3.6", + "choices": [ + { + "text": " Paris.", + "index": 0, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/completions \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"model\": \"qwen3.6\", \"prompt\": \"The capital of France is\", \"max_tokens\": 16 }'" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.completions.create(\n model=\"qwen3.6\",\n prompt=\"The capital of France is\",\n max_tokens=16,\n)\nprint(resp.choices[0].text)" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nconst resp = await client.completions.create({\n model: \"qwen3.6\",\n prompt: \"The capital of France is\",\n max_tokens: 16,\n});\nconsole.log(resp.choices[0].text);" + } + ] + } + }, + "/embeddings": { + "post": { + "operationId": "createEmbedding", + "tags": [ + "Embeddings" + ], + "summary": "Create embeddings", + "description": "Creates an embedding vector representing the input text. Pair with [Rerank](#tag/Rerank) to build a retrieval stack. Model: `qwen3-embedding` (4096-dimension vectors).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "model", + "input" + ], + "properties": { + "model": { + "type": "string", + "enum": [ + "qwen3-embedding" + ], + "description": "The embedding model. Currently `qwen3-embedding`.", + "example": "qwen3-embedding" + }, + "input": { + "description": "The text to embed: a single string, or an array of strings to embed in one request.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "example": [ + "Hola mundo", + "Hello world" + ] + }, + "encoding_format": { + "type": "string", + "enum": [ + "float", + "base64" + ], + "default": "float", + "description": "The format of the returned vectors: `float` (default) or `base64`." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The embeddings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingList" + }, + "example": { + "object": "list", + "model": "qwen3-embedding", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [ + 0.021, + 0.0105, + -0.0204, + "..." + ] + } + ], + "usage": { + "prompt_tokens": 3, + "total_tokens": 3 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/embeddings \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"model\": \"qwen3-embedding\", \"input\": [\"Hola mundo\", \"Hello world\"] }'" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.embeddings.create(\n model=\"qwen3-embedding\",\n input=[\"Hola mundo\", \"Hello world\"],\n)\nprint(resp.data[0].embedding[:3])" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nconst resp = await client.embeddings.create({\n model: \"qwen3-embedding\",\n input: [\"Hola mundo\", \"Hello world\"],\n});\nconsole.log(resp.data[0].embedding.slice(0, 3));" + } + ] + } + }, + "/rerank": { + "post": { + "operationId": "rerank", + "tags": [ + "Rerank" + ], + "summary": "Rerank documents", + "description": "Reranks a list of documents by their relevance to a query, returning them ordered most-relevant first. Completes the RAG stack with `qwen3-embedding`: retrieve top-K by embedding similarity, then reorder here. Supports 100+ languages and code. Model: `rerank`. Also available at `/v2/rerank`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "model", + "query", + "documents" + ], + "properties": { + "model": { + "type": "string", + "enum": [ + "rerank" + ], + "description": "The rerank model. Currently `rerank` (Qwen3-Reranker-8B).", + "example": "rerank" + }, + "query": { + "type": "string", + "description": "The query each document is scored against.", + "example": "What is the capital of France?" + }, + "documents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The documents to rerank. Returned sorted by descending relevance, each keeping its original `index`.", + "example": [ + "Paris is the capital of France.", + "Berlin is the capital of Germany.", + "Madrid is the capital of Spain." + ] + }, + "top_n": { + "type": "integer", + "minimum": 1, + "description": "Return only the `top_n` most relevant documents. Defaults to all." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The reranked documents.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RerankResponse" + }, + "example": { + "id": "score-a032ee5767cab0ee", + "results": [ + { + "index": 0, + "relevance_score": 0.739, + "document": { + "text": "Paris is the capital of France." + } + }, + { + "index": 1, + "relevance_score": 0.6, + "document": { + "text": "Berlin is the capital of Germany." + } + } + ], + "meta": { + "billed_units": { + "total_tokens": 43 + }, + "tokens": { + "input_tokens": 43 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/rerank \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"model\": \"rerank\",\n \"query\": \"What is the capital of France?\",\n \"documents\": [\"Paris is the capital of France.\", \"Berlin is the capital of Germany.\"]\n }'" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\n# rerank isn't part of the OpenAI client — call it directly:\nresp = client.post(\n \"/rerank\",\n cast_to=object,\n body={\n \"model\": \"rerank\",\n \"query\": \"What is the capital of France?\",\n \"documents\": [\"Paris is the capital of France.\", \"Berlin is the capital of Germany.\"],\n },\n)\nfor r in resp[\"results\"]:\n print(r[\"index\"], r[\"relevance_score\"])" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\n// rerank isn't part of the OpenAI client — call it directly:\nconst res = await fetch(\"https://api.nan.builders/v1/rerank\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${process.env.NAN_API_KEY}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model: \"rerank\",\n query: \"What is the capital of France?\",\n documents: [\"Paris is the capital of France.\", \"Berlin is the capital of Germany.\"],\n }),\n});\nconst data = await res.json();\nconsole.log(data.results);" + } + ] + } + }, + "/audio/speech": { + "post": { + "operationId": "createSpeech", + "tags": [ + "Audio" + ], + "summary": "Create speech", + "description": "Generates audio from the input text (text-to-speech). Returns the raw audio file, not JSON. Model: `kokoro`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "model", + "input", + "voice" + ], + "properties": { + "model": { + "type": "string", + "enum": [ + "kokoro" + ], + "description": "The TTS model. Currently `kokoro`.", + "example": "kokoro" + }, + "input": { + "type": "string", + "description": "The text to synthesize into audio.", + "example": "Bienvenido a NaN." + }, + "voice": { + "type": "string", + "description": "The voice to use, e.g. `af_heart` (English female), `ef_dora` (Spanish female) or `em_alex` (Spanish male).", + "example": "ef_dora" + }, + "response_format": { + "type": "string", + "enum": [ + "mp3", + "wav", + "flac", + "aac", + "pcm", + "opus" + ], + "default": "mp3", + "description": "The audio format to return." + }, + "speed": { + "type": "number", + "default": 1, + "description": "Playback speed multiplier." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The generated audio file, in the requested format.", + "content": { + "audio/mpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/audio/speech \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"model\": \"kokoro\", \"voice\": \"ef_dora\", \"input\": \"Bienvenido a NaN.\" }' \\\n -o speech.mp3" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nspeech = client.audio.speech.create(\n model=\"kokoro\",\n voice=\"ef_dora\",\n input=\"Bienvenido a NaN.\",\n)\nspeech.stream_to_file(\"speech.mp3\")" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nimport fs from \"node:fs\";\nconst speech = await client.audio.speech.create({\n model: \"kokoro\",\n voice: \"ef_dora\",\n input: \"Bienvenido a NaN.\",\n});\nfs.writeFileSync(\"speech.mp3\", Buffer.from(await speech.arrayBuffer()));" + } + ] + } + }, + "/audio/transcriptions": { + "post": { + "operationId": "createTranscription", + "tags": [ + "Audio" + ], + "summary": "Create transcription", + "description": "Transcribes audio into text (speech-to-text). Model: `whisper`. Send the audio as `multipart/form-data`.\n\nLimits: max 25 MB per request. Audio longer than ~2 minutes may time out (`524`); split it into shorter segments. OGG/Opus and MP3 give the best size-to-quality ratio.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file", + "model" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The audio file to transcribe." + }, + "model": { + "type": "string", + "enum": [ + "whisper" + ], + "description": "The transcription model. Currently `whisper`." + }, + "language": { + "type": "string", + "description": "The input language as an ISO-639-1 code (e.g. `es`, `en`). Auto-detected if omitted." + }, + "response_format": { + "type": "string", + "enum": [ + "json", + "verbose_json" + ], + "default": "json", + "description": "Response shape. `verbose_json` adds `duration` and `segments`." + }, + "timestamp_granularities[]": { + "type": "string", + "enum": [ + "word", + "segment" + ], + "description": "Timestamp detail (with `verbose_json`): `word` fills the `words` array; `segment` (default) fills `segments`." + }, + "temperature": { + "type": "number", + "description": "Sampling temperature for the decoder." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The transcription (example shown for `verbose_json`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Transcription" + }, + "example": { + "text": "Hola, esto es una prueba.", + "language": "es", + "task": "transcribe", + "duration": 1.728, + "segments": [ + { + "id": 1, + "start": 0, + "end": 1.4, + "text": " Hola, esto es una prueba.", + "avg_logprob": -0.059, + "no_speech_prob": 0.044 + } + ], + "words": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "524": { + "description": "Timeout: the audio was too long to transcribe in one request. Split it into segments of 2 minutes or less." + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/audio/transcriptions \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -F \"model=whisper\" \\\n -F \"file=@audio.mp3\"" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.audio.transcriptions.create(\n model=\"whisper\",\n file=open(\"audio.mp3\", \"rb\"),\n)\nprint(resp.text)" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nimport fs from \"node:fs\";\nconst resp = await client.audio.transcriptions.create({\n model: \"whisper\",\n file: fs.createReadStream(\"audio.mp3\"),\n});\nconsole.log(resp.text);" + } + ] + } + }, + "/responses": { + "post": { + "operationId": "createResponse", + "tags": [ + "Responses" + ], + "summary": "Create response", + "description": "Creates a model response using the OpenAI-style Responses API. Models: `qwen3.6`, `gemma4`. Streaming currently emits a single terminal event; for token-by-token streaming, use [Create chat completion](#tag/Chat) with `stream: true`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "model", + "input" + ], + "properties": { + "model": { + "type": "string", + "enum": [ + "qwen3.6", + "gemma4" + ], + "description": "The model to use.", + "example": "qwen3.6" + }, + "input": { + "description": "The input: a single string, or an array of messages in the Responses format.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object" + } + } + ], + "example": "Hola, ¿cómo estás?" + }, + "max_output_tokens": { + "type": "integer", + "minimum": 1, + "description": "The maximum number of output tokens. Default 65536 on `qwen3.6`." + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "default": 0.6, + "description": "Sampling temperature." + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.95, + "description": "Nucleus sampling." + }, + "instructions": { + "type": "string", + "description": "System-level instructions that steer the model." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseObject" + }, + "example": { + "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 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/responses \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"model\": \"qwen3.6\", \"input\": \"Hola, ¿cómo estás?\" }'" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.responses.create(\n model=\"qwen3.6\",\n input=\"Hola, ¿cómo estás?\",\n)\nprint(resp.output_text)" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nconst resp = await client.responses.create({\n model: \"qwen3.6\",\n input: \"Hola, ¿cómo estás?\",\n});\nconsole.log(resp.output_text);" + } + ] + } + }, + "/images/generations": { + "post": { + "operationId": "createImage", + "tags": [ + "Images" + ], + "summary": "Create image", + "description": "Creates one or more images from a text prompt (text-to-image). Model: `flux-2-klein`.\n\nRequires `inference`-tier membership (`403` otherwise). Image endpoints are rate-limited separately from chat: 20 requests per minute and 100 requests per month per user. These don't consume your chat token budget. `stream: true` is not supported. For OpenAI SDK compatibility, `quality`, `style`, `background`, `moderation`, `output_format`, `output_compression`, and `user` are accepted but ignored.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string", + "description": "A text description of the image to generate.", + "example": "Un faro al atardecer sobre acantilados, estilo cinemático" + }, + "model": { + "type": "string", + "default": "flux-2-klein", + "description": "The image model. An unknown model returns `404`.", + "example": "flux-2-klein" + }, + "n": { + "type": "integer", + "minimum": 1, + "maximum": 4, + "default": 1, + "description": "The number of images to generate (1–4). Values above 4 return `400`." + }, + "size": { + "type": "string", + "default": "1024x1024", + "description": "\"WIDTHxHEIGHT\". Each side divisible by 16 and between 256 and 1536, aspect ratio 1:3–3:1. `auto` or omitted → `1024x1024`.", + "example": "1024x1024" + }, + "response_format": { + "type": "string", + "enum": [ + "url", + "b64_json" + ], + "default": "url", + "description": "`url` returns a temporary link (~60 min); `b64_json` returns inline base64 bytes." + }, + "seed": { + "type": "integer", + "description": "NaN extension: base seed for reproducibility (in OpenAI SDKs, pass via `extra_body`)." + }, + "guidance": { + "type": "number", + "description": "NaN extension: FLUX guidance scale (in OpenAI SDKs, pass via `extra_body`)." + }, + "enhance_prompt": { + "type": "boolean", + "default": false, + "description": "NaN extension: when `true`, an AI model first rewrites your prompt into a richer, more detailed description before generation — enriching style, lighting and composition without adding new subjects. Defaults to `false` on the API (in OpenAI SDKs, pass via `extra_body`). The rewritten prompt is used internally and is not returned in the response." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The generated images.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageResponse" + }, + "example": { + "created": 1778258200, + "data": [ + { + "url": "https://...r2.../image.png" + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "The model does not exist (`model_not_found`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/images/generations \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"model\": \"flux-2-klein\", \"prompt\": \"Un faro al atardecer sobre acantilados\", \"size\": \"1024x1024\" }'" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nimg = client.images.generate(\n model=\"flux-2-klein\",\n prompt=\"Un faro al atardecer sobre acantilados\",\n size=\"1024x1024\",\n)\nprint(img.data[0].url)" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nconst img = await client.images.generate({\n model: \"flux-2-klein\",\n prompt: \"Un faro al atardecer sobre acantilados\",\n size: \"1024x1024\",\n});\nconsole.log(img.data[0].url);" + } + ] + } + }, + "/images/edits": { + "post": { + "operationId": "editImage", + "tags": [ + "Images" + ], + "summary": "Edit image", + "description": "Creates an image from one or more reference images and a prompt (image-to-image). Model: `flux-2-klein`. Send images as `multipart/form-data`. Same `inference`-tier membership and monthly quota as [Create image](#tag/Images). `mask` is not supported (returns `400`).", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "image", + "prompt" + ], + "properties": { + "image": { + "type": "string", + "format": "binary", + "description": "One or more reference images (up to 4; extras are dropped). PNG, JPEG or WebP, each under 25 MB. Repeat the field as `image[]` for multiple." + }, + "prompt": { + "type": "string", + "description": "A description of the edit or transformation to apply." + }, + "model": { + "type": "string", + "default": "flux-2-klein", + "description": "The image model." + }, + "n": { + "type": "integer", + "minimum": 1, + "maximum": 4, + "default": 1, + "description": "The number of images to generate (1–4)." + }, + "size": { + "type": "string", + "default": "1024x1024", + "description": "Output size, same rules as Create image." + }, + "response_format": { + "type": "string", + "enum": [ + "url", + "b64_json" + ], + "default": "url", + "description": "`url` (default) or `b64_json`." + }, + "seed": { + "type": "integer", + "description": "NaN extension: base seed (form field)." + }, + "guidance": { + "type": "number", + "description": "NaN extension: FLUX guidance scale (form field)." + }, + "enhance_prompt": { + "type": "boolean", + "default": false, + "description": "NaN extension: when `true`, an AI model first rewrites your prompt into a richer description before generation (subjects unchanged). Send as a form field; defaults to `false`. The rewritten prompt is used internally and is not returned." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The generated image(s).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageResponse" + }, + "example": { + "created": 1778258200, + "data": [ + { + "url": "https://...r2.../image.png" + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/images/edits \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -F \"model=flux-2-klein\" \\\n -F \"image[]=@ref.png\" \\\n -F \"prompt=Convierte la escena en invierno con nieve\"" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nimg = client.images.edit(\n model=\"flux-2-klein\",\n image=open(\"ref.png\", \"rb\"),\n prompt=\"Convierte la escena en invierno con nieve\",\n)\nprint(img.data[0].url)" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\nimport fs from \"node:fs\";\nconst img = await client.images.edit({\n model: \"flux-2-klein\",\n image: fs.createReadStream(\"ref.png\"),\n prompt: \"Convierte la escena en invierno con nieve\",\n});\nconsole.log(img.data[0].url);" + } + ] + } + }, + "/search": { + "post": { + "operationId": "search", + "tags": [ + "Search" + ], + "summary": "Web search", + "description": "An authenticated web search tool for agents. Give it a query and it returns ranked results (title, URL, snippet, source), drawn from a hybrid of upstream search providers. Results come back through the NaN API — your key never talks to a third-party search provider directly, and you never hold a provider key.\n\nSet `fetch_content: true` to also extract the readable main text of the top results (adds latency). You supply only `query`; when `fetch_content` is `true` the server fetches page content **only** for the URLs the search returned, through a hardened anti-SSRF fetcher — you cannot pass an arbitrary URL to fetch.\n\nLimits are per API key and separate from the model endpoints, so searching does not consume your chat RPM budget or vice versa: 20 requests per minute, 3 concurrent, and 500 searches per day. Exceeding the per-minute or concurrency limit returns `429` `rate_limit_exceeded`; exhausting the daily quota returns `429` `insufficient_quota`. A `429` carries a `Retry-After` header. Repeated identical queries within ~15 minutes are served from a short-lived cache (`cached: true`); cached hits still count toward your rate and quota.\n\n## Use it as an agent tool\n\nDrop this OpenAI-style function schema into your model call's `tools` array. When the model emits a `web_search` tool call, invoke `POST /v1/search` with the arguments and feed the JSON response back as the tool result.\n\n```json\n{\n \"type\": \"function\",\n \"function\": {\n \"name\": \"web_search\",\n \"description\": \"Search the public web and return relevant results (title, URL, snippet, and optional page content). Use it when the answer may depend on current events, recent developments, prices, release/version numbers, or facts you are not confident are up to date.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": { \"type\": \"string\", \"description\": \"The web search query.\" },\n \"count\": { \"type\": \"integer\", \"description\": \"Number of results to return (1-20).\", \"minimum\": 1, \"maximum\": 20, \"default\": 5 },\n \"freshness\": { \"type\": \"string\", \"description\": \"Restrict results by recency: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or a 'YYYY-MM-DDtoYYYY-MM-DD' date range. Omit for no time filter.\" },\n \"fetch_content\": { \"type\": \"boolean\", \"description\": \"When true, also fetch and include the readable text of the top results (slower). Default false returns snippets only.\", \"default\": false }\n },\n \"required\": [\"query\"]\n }\n }\n}\n```", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "query" + ], + "properties": { + "query": { + "type": "string", + "description": "The search query.", + "example": "latest go release" + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 5, + "description": "Number of results to return, 1–20 (values outside the range are clamped).", + "example": 5 + }, + "freshness": { + "type": "string", + "description": "Recency filter: `pd` (past day), `pw` (past week), `pm` (past month), `py` (past year), or a `YYYY-MM-DDtoYYYY-MM-DD` date range. Omit for no time filter.", + "example": "pw" + }, + "fetch_content": { + "type": "boolean", + "default": false, + "description": "When `true`, also fetch and include the readable main text of the top results (`content`). Slower; defaults to snippets only.", + "example": false + } + } + }, + "example": { + "query": "latest go release", + "count": 5, + "freshness": "pw", + "fetch_content": false + } + } + } + }, + "responses": { + "200": { + "description": "The search results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + }, + "example": { + "results": [ + { + "title": "Go 1.23 is released", + "url": "https://go.dev/blog/go1.23", + "snippet": "The latest Go release adds ...", + "source": "primary" + } + ], + "cached": false + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "503": { + "description": "Search is temporarily unavailable (`search_unavailable`); retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/search \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"query\": \"latest go release\", \"count\": 5, \"freshness\": \"pw\" }'" + }, + { + "lang": "python", + "label": "Python", + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\n# search isn't part of the OpenAI client — call it directly:\nresp = client.post(\n \"/search\",\n cast_to=object,\n body={\"query\": \"latest go release\", \"count\": 5, \"freshness\": \"pw\"},\n)\nfor r in resp[\"results\"]:\n print(r[\"title\"], r[\"url\"])" + }, + { + "lang": "javascript", + "label": "Node.js", + "source": "const res = await fetch(\"https://api.nan.builders/v1/search\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${process.env.NAN_API_KEY}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ query: \"latest go release\", count: 5, freshness: \"pw\" }),\n});\nconst data = await res.json();\nfor (const r of data.results) console.log(r.title, r.url);" + } + ] + } + }, + "/mcp": { + "post": { + "operationId": "mcpJsonRpc", + "tags": [ + "MCP" + ], + "summary": "MCP server (JSON-RPC)", + "description": "Remote [Model Context Protocol](https://modelcontextprotocol.io) server, so our tools can be used inside any MCP-compatible agent or client with the same `sk-` key as the REST API.\n\nTransport is streamable HTTP and stateless; the protocol is JSON-RPC 2.0 with the methods `initialize`, `tools/list`, `tools/call` and `ping`. Today the server exposes a single tool, `web_search`, with the same arguments as [Web search](#tag/Search); it is a growing registry, so use `tools/list` to discover the current set.\n\nMCP calls share the **same** per-key rate limit, daily quota and concurrency as the equivalent REST endpoint — there is no separate budget. A `web_search` tool call over MCP counts exactly like a `POST /v1/search` request.\n\nNote this endpoint lives at the host root, `https://api.nan.builders/mcp`, not under `/v1`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonRpcRequest" + }, + "example": { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "web_search", + "arguments": { + "query": "kubernetes 1.34 release", + "count": 5 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A JSON-RPC 2.0 response. Protocol-level failures are reported in `error` with HTTP `200`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonRpcResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/mcp \\\n -H \"Authorization: Bearer $NAN_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"web_search\",\n \"arguments\": { \"query\": \"kubernetes 1.34 release\", \"count\": 5 }\n }\n }'" + }, + { + "lang": "json", + "label": "MCP client config", + "source": "{\n \"mcpServers\": {\n \"nan\": {\n \"url\": \"https://api.nan.builders/mcp\",\n \"headers\": {\n \"Authorization\": \"Bearer sk-your-key-here\"\n }\n }\n }\n}" + } + ] + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Personal, non-transferable API key. Send as `Authorization: Bearer ` on every request." + } + }, + "schemas": { + "Model": { + "type": "object", + "description": "An available model, in the OpenAI-compatible model shape.", + "properties": { + "id": { + "type": "string", + "description": "The model's unique slug. Pass this value as the `model` field in your requests.", + "example": "qwen3.6" + }, + "object": { + "type": "string", + "enum": [ + "model" + ], + "description": "The object type, which is always `model`.", + "example": "model" + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the model became available.", + "example": 1677610602 + }, + "owned_by": { + "type": "string", + "description": "The organization that owns the model.", + "example": "nan" + } + } + }, + "ContentPart": { + "description": "One part of a multimodal message. Use an array of these as `content` for vision input.", + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "text" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "image_url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "image_url" + ] + }, + "image_url": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Public URL or `data:` URI of the image." + } + } + } + } + } + ] + }, + "Message": { + "type": "object", + "required": [ + "role" + ], + "description": "A single message in a conversation. The `role` determines which other fields apply.", + "properties": { + "role": { + "type": "string", + "enum": [ + "system", + "user", + "assistant", + "tool" + ], + "description": "The role of the message author: `system`, `user`, `assistant`, or `tool`." + }, + "content": { + "description": "The contents of the message. A plain string, or an array of content parts for multimodal input. Can be `null` on an `assistant` message that only returns tool calls.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentPart" + } + } + ] + }, + "name": { + "type": "string", + "description": "An optional name for the author of this message." + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolCall" + }, + "description": "The tool calls generated by the model. Present on `assistant` messages when the model decides to invoke one or more tools." + }, + "tool_call_id": { + "type": "string", + "description": "The id of the tool call this message answers. Required when `role` is `tool`." + }, + "reasoning_content": { + "type": "string", + "description": "The model's reasoning trace. Present only for reasoning models; safe to display or ignore." + } + } + }, + "Tool": { + "type": "object", + "required": [ + "type", + "function" + ], + "description": "A function the model may call. Provide these in the request's `tools` array.", + "properties": { + "type": { + "type": "string", + "enum": [ + "function" + ], + "description": "The type of the tool. Currently only `function` is supported." + }, + "function": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the function to call. Up to 64 characters; letters, digits, underscores and dashes." + }, + "description": { + "type": "string", + "description": "What the function does, used by the model to decide when and how to call it. Be descriptive." + }, + "parameters": { + "type": "object", + "description": "The parameters the function accepts, described as a JSON Schema object." + } + } + } + } + }, + "ToolChoice": { + "description": "Controls tool use: `\"auto\"` (default), `\"none\"`, `\"required\"`, or force one with `{ \"type\": \"function\", \"function\": { \"name\": \"...\" } }`.", + "oneOf": [ + { + "type": "string", + "enum": [ + "auto", + "none", + "required" + ] + }, + { + "type": "object" + } + ] + }, + "ToolCall": { + "type": "object", + "description": "A tool the model chose to call. Execute it, then send the result back in a `tool` message referencing `id`.", + "properties": { + "id": { + "type": "string", + "description": "A unique id for this tool call. Pass it as `tool_call_id` in your follow-up `tool` message." + }, + "type": { + "type": "string", + "enum": [ + "function" + ], + "description": "The type of the tool call. Always `function`." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the function to call." + }, + "arguments": { + "type": "string", + "description": "The function arguments as a JSON-encoded string. Parse and validate it before use; models can occasionally emit malformed JSON." + } + } + } + } + }, + "ResponseFormat": { + "description": "Force structured output. `json_object` guarantees syntactically valid JSON; `json_schema` (with `strict: true`) constrains the output to a schema. Works on `qwen3.6` and `gemma4`.", + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "json_object" + ] + } + } + }, + { + "type": "object", + "required": [ + "type", + "json_schema" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "json_schema" + ] + }, + "json_schema": { + "type": "object", + "required": [ + "name", + "schema" + ], + "properties": { + "name": { + "type": "string" + }, + "strict": { + "type": "boolean" + }, + "schema": { + "type": "object" + } + } + } + } + } + ] + }, + "Usage": { + "type": "object", + "description": "Token accounting for the request.", + "properties": { + "prompt_tokens": { + "type": "integer", + "description": "The number of tokens in the prompt (input)." + }, + "completion_tokens": { + "type": "integer", + "description": "The number of tokens in the generated completion (output)." + }, + "total_tokens": { + "type": "integer", + "description": "The total tokens used: `prompt_tokens` plus `completion_tokens`. This is what counts against your quota." + } + } + }, + "ChatCompletion": { + "type": "object", + "description": "The model's response to a chat request. Represents a single, non-streamed completion.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the completion." + }, + "object": { + "type": "string", + "enum": [ + "chat.completion" + ], + "description": "The object type, always `chat.completion`." + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the completion was created." + }, + "model": { + "type": "string", + "description": "The model that produced the completion." + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "description": "The index of this choice in the `choices` array." + }, + "finish_reason": { + "type": "string", + "enum": [ + "stop", + "length", + "tool_calls" + ], + "description": "Why the model stopped: `stop` (natural end or a stop sequence), `length` (hit `max_tokens`), or `tool_calls` (the model called a tool)." + }, + "message": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "The list of completion choices. Contains a single choice unless you request more." + }, + "usage": { + "$ref": "#/components/schemas/Usage", + "description": "Token usage for the request." + } + } + }, + "ChatCompletionChunk": { + "type": "object", + "description": "One Server-Sent Event chunk when `stream: true`. New text arrives in `choices[0].delta.content`; the stream ends with `data: [DONE]`.", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "enum": [ + "chat.completion.chunk" + ], + "description": "The object type, always `chat.completion.chunk`." + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the chunk was created. Identical across all chunks of one response." + }, + "model": { + "type": "string", + "description": "The model producing the response." + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "delta": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "type": "string" + }, + "reasoning_content": { + "type": "string" + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolCall" + } + } + }, + "description": "The incremental update for this chunk. `role` is set on the first chunk; `content` carries the new text to append." + }, + "finish_reason": { + "type": [ + "string", + "null" + ], + "description": "`null` on intermediate chunks; set to the stop reason on the final content chunk." + } + } + } + } + } + }, + "Error": { + "type": "object", + "description": "OpenAI-standard error envelope: a non-2xx HTTP status with a JSON body describing the problem.", + "properties": { + "error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "param": { + "type": [ + "string", + "null" + ] + }, + "code": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "example": { + "error": { + "message": "...", + "type": null, + "param": null, + "code": "..." + } + } + }, + "Embedding": { + "type": "object", + "description": "A single embedding vector for one input.", + "properties": { + "object": { + "type": "string", + "enum": [ + "embedding" + ], + "description": "The object type, always `embedding`." + }, + "index": { + "type": "integer", + "description": "The position of this embedding in the request `input` array." + }, + "embedding": { + "type": "array", + "items": { + "type": "number" + }, + "description": "The embedding vector: a list of 4096 floats. Cosine similarity between vectors measures semantic closeness." + } + } + }, + "EmbeddingList": { + "type": "object", + "description": "A list of embeddings, one per input.", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ], + "description": "Always `list`." + }, + "model": { + "type": "string", + "description": "The model used to create the embeddings." + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Embedding" + }, + "description": "The embeddings, in the same order as the request `input`." + }, + "usage": { + "type": "object", + "description": "Token usage for the request.", + "properties": { + "prompt_tokens": { + "type": "integer", + "description": "Tokens in the input." + }, + "total_tokens": { + "type": "integer", + "description": "Total tokens used." + } + } + } + } + }, + "RerankResult": { + "type": "object", + "description": "One reranked document.", + "properties": { + "index": { + "type": "integer", + "description": "The position of this document in the request `documents` array." + }, + "relevance_score": { + "type": "number", + "description": "Relevance to the query, in the range [0, 1]. Higher is more relevant. Results are returned sorted by this, descending." + }, + "document": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The document text." + } + }, + "description": "The document, echoed back." + } + } + }, + "RerankResponse": { + "type": "object", + "description": "The reranked documents, most-relevant first.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the rerank request." + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RerankResult" + }, + "description": "The documents, sorted by descending `relevance_score`. Each carries its original `index`." + }, + "meta": { + "type": "object", + "description": "Request metadata, including token accounting.", + "properties": { + "billed_units": { + "type": "object", + "properties": { + "total_tokens": { + "type": "integer" + } + } + }, + "tokens": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + } + } + } + } + } + } + }, + "Transcription": { + "type": "object", + "description": "A transcription of the supplied audio.", + "properties": { + "text": { + "type": "string", + "description": "The full transcribed text." + }, + "language": { + "type": "string", + "description": "The detected (or supplied) language, as an ISO-639-1 code." + }, + "task": { + "type": "string", + "description": "The task performed, always `transcribe`." + }, + "duration": { + "type": "number", + "description": "The audio duration in seconds. Present with `verbose_json`." + }, + "segments": { + "type": "array", + "description": "Time-aligned segments. Present with `verbose_json`.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "start": { + "type": "number", + "description": "Segment start time in seconds." + }, + "end": { + "type": "number", + "description": "Segment end time in seconds." + }, + "text": { + "type": "string" + }, + "avg_logprob": { + "type": "number" + }, + "no_speech_prob": { + "type": "number" + } + } + } + }, + "words": { + "type": [ + "array", + "null" + ], + "description": "Per-word timestamps `[{ word, start, end, probability }]`. Populated only when `timestamp_granularities[]=word`." + } + } + }, + "ResponseObject": { + "type": "object", + "description": "A response produced by the Responses API.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the response." + }, + "created_at": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the response was created." + }, + "model": { + "type": "string", + "description": "The model that produced the response." + }, + "object": { + "type": "string", + "enum": [ + "response" + ], + "description": "The object type, always `response`." + }, + "status": { + "type": "string", + "description": "The response status, e.g. `completed`." + }, + "output": { + "type": "array", + "description": "The output items. May contain `reasoning` blocks (reasoning models) followed by a `message` block with the reply text in `content[].text`.", + "items": { + "type": "object" + } + }, + "usage": { + "type": "object", + "description": "Token usage for the request.", + "properties": { + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" + } + } + } + } + }, + "ImageResponse": { + "type": "object", + "description": "The generated image(s).", + "properties": { + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the images were created." + }, + "data": { + "type": "array", + "description": "The generated images. Each item has a `url` (default) or a `b64_json` payload, depending on `response_format`.", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "A temporary URL to the image, valid ~60 minutes. Returned when `response_format` is `url`." + }, + "b64_json": { + "type": "string", + "description": "The base64-encoded image bytes. Returned when `response_format` is `b64_json`." + } + } + } + } + } + }, + "Completion": { + "type": "object", + "description": "A completion for the provided prompt (legacy).", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "enum": [ + "text_completion" + ], + "description": "Always `text_completion`." + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the completion was created." + }, + "model": { + "type": "string" + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The generated text." + }, + "index": { + "type": "integer" + }, + "finish_reason": { + "type": "string", + "enum": [ + "stop", + "length" + ], + "description": "Why generation stopped." + }, + "logprobs": { + "type": [ + "object", + "null" + ] + } + } + } + }, + "usage": { + "$ref": "#/components/schemas/Usage" + } + } + }, + "SearchResult": { + "type": "object", + "description": "One web search result.", + "properties": { + "title": { + "type": "string", + "description": "The result title (plain text)." + }, + "url": { + "type": "string", + "description": "The result URL." + }, + "snippet": { + "type": "string", + "description": "A short excerpt from the page." + }, + "content": { + "type": "string", + "description": "The readable main text of the page. Present only when `fetch_content` was `true` and the fetch succeeded." + }, + "source": { + "type": "string", + "description": "Which tier served this result: `primary` or `fallback`." + } + } + }, + "SearchResponse": { + "type": "object", + "description": "The web search results.", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchResult" + }, + "description": "The ranked results, most relevant first." + }, + "cached": { + "type": "boolean", + "description": "`true` when the results came from the short-lived (~15 min) exact-query cache." + } + } + }, + "JsonRpcRequest": { + "type": "object", + "required": [ + "jsonrpc", + "method" + ], + "properties": { + "jsonrpc": { + "type": "string", + "enum": [ + "2.0" + ], + "description": "Always `2.0`." + }, + "id": { + "description": "Request id, echoed back in the response.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "method": { + "type": "string", + "enum": [ + "initialize", + "tools/list", + "tools/call", + "ping" + ], + "description": "The JSON-RPC method to invoke." + }, + "params": { + "type": "object", + "additionalProperties": true, + "description": "Method arguments. For `tools/call`: `name` (the tool) and `arguments` (its input)." + } + } + }, + "JsonRpcResponse": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": [ + "2.0" + ] + }, + "id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "result": { + "type": "object", + "additionalProperties": true, + "description": "Present on success. Shape depends on the method." + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "description": "Present on a protocol-level failure." + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid parameter. The body includes `param` with the offending field. Safety filter returns `content_policy_violation`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing `Authorization` header (`invalid_api_key`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Forbidden": { + "description": "Your tier lacks access to this endpoint (`tier_restricted`). Image generation requires `inference` membership.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded: `rpm_limit` or `max_parallel_requests` (`rate_limit_exceeded`), or monthly quota exhausted (`quota_exceeded` / `insufficient_quota`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "PaymentRequired": { + "description": "The token allowance for the billing period is spent on a model that carries one, such as `glm5.2`. The request was refused before it reached the model, so nothing ran. Not retryable: the counter returns to zero when your billing period starts. The wire `code` is `cap_reached` or `monthly_cap_reached`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Token allowance for this billing period exhausted for model glm5.2. The counter returns to zero when your next billing period starts.", + "type": "monthly_cap_reached", + "param": null, + "code": "monthly_cap_reached" + } + } + } + } + } + } + }, + "externalDocs": { + "description": "NaN Docs", + "url": "https://nan.builders/docs" + } +} diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro index ef7bad7..4600fac 100644 --- a/src/layouts/Docs.astro +++ b/src/layouts/Docs.astro @@ -9,6 +9,7 @@ import '@fontsource/jetbrains-mono/500.css'; import '@fontsource/jetbrains-mono/700.css'; import { getCollection } from 'astro:content'; +import { API_DOC_META, API_DOC_SLUG } from '../lib/apiDoc'; interface Props { title: string; @@ -28,17 +29,28 @@ const ogImage = `${siteUrl}/og/og-en.png`; const socialTitle = `${title} — NaN Docs`; const entries = await getCollection('docs'); -entries.sort((a, b) => a.data.order - b.data.order); interface NavItem { slug: string; label: string; + order: number; } -const navItems: NavItem[] = entries.map((entry) => ({ - slug: entry.id === 'intro' ? '/docs' : `/docs/${entry.id}`, - label: entry.data.title, -})); +/* + * La referencia de API no sale de la colección: /docs/api la sirve Scalar desde + * el spec (src/data/openapi.json), así que no hay entry del que sacar título ni + * orden. Se añade a mano para que siga estando en la navegación en el sitio + * donde estaba. Los metadatos vienen de apiDoc.ts, el mismo módulo del que los + * lee /api/docs, para que la página y el manifest no puedan discrepar. + */ +const navItems: NavItem[] = [ + ...entries.map((entry) => ({ + slug: entry.id === 'intro' ? '/docs' : `/docs/${entry.id}`, + label: entry.data.title, + order: entry.data.order, + })), + { slug: `/docs/${API_DOC_SLUG}`, label: API_DOC_META.title, order: API_DOC_META.order }, +].sort((a, b) => a.order - b.order); const currentPageIndex = navItems.findIndex((item) => item.slug === Astro.url.pathname); const prevPage = currentPageIndex > 0 ? navItems[currentPageIndex - 1] : null; diff --git a/src/lib/apiDoc.ts b/src/lib/apiDoc.ts new file mode 100644 index 0000000..845a55b --- /dev/null +++ b/src/lib/apiDoc.ts @@ -0,0 +1,36 @@ +import spec from '../data/openapi.json'; +import { openapiToText } from './openapiToText'; + +/** + * La referencia de API como entrada de docs, generada desde el spec. + * + * Desde que /docs/api la sirve Scalar, `api` ya no es un fichero de la + * colección: sale de src/data/openapi.json. Pero los consumidores de + * /api/docs —el bot de Discord, hoy— no tienen por qué enterarse de eso, así + * que se sigue publicando con el mismo slug, el mismo orden y la misma forma + * que tenía cuando era `api.mdx`. El contrato del manifest no cambia; cambia + * de dónde sale el texto. + * + * Los metadatos replican el frontmatter que tenía `api.mdx` (incluido + * `order: 2`) para que ni el orden del manifest ni el de la navegación se + * muevan con la migración. + */ +export const API_DOC_SLUG = 'api'; + +export const API_DOC_META = { + title: 'API', + description: 'Public API endpoint reference. OpenAI-compatible.', + order: 2, +} as const; + +let cached: string | null = null; + +/** + * El texto canónico de la referencia. Se memoiza porque el spec es estático + * dentro de un despliegue: recorrer 12 endpoints y 23 esquemas en cada + * petición del manifest sería trabajo repetido para un resultado idéntico. + */ +export function getApiDocText(): string { + if (cached === null) cached = openapiToText(spec); + return cached; +} diff --git a/src/lib/canonicalParity.test.ts b/src/lib/canonicalParity.test.ts index 7b3b907..97e5673 100644 --- a/src/lib/canonicalParity.test.ts +++ b/src/lib/canonicalParity.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { getApiDocText } from './apiDoc'; import { mdxToText, normalizeCanonicalText } from './mdxToText'; /** @@ -70,6 +71,33 @@ const corpus: Array<{ label: string; body: string }> = [ })), ]; +/** + * La referencia de API ya no pasa por mdxToText: se genera desde el spec. Sigue + * viajando por el mismo sitio (/api/docs/api.md) y la vuelve a canonicalizar el + * mismo bot, así que tiene que cumplir exactamente las mismas propiedades. Sin + * esto, el hash del manifest y el que calcula el bot no coincidirían nunca y + * reindexaría la referencia entera en cada versión. + */ +describe('openapiToText output is a fixed point of the bot canonicaliser', () => { + const out = getApiDocText(); + + it('canonicalize(x) === x', () => { + expect(canonicalizeDocText(out, { stripFrontmatter: false })).toBe(out); + }); + + it('stable under strip_frontmatter=True too', () => { + expect(canonicalizeDocText(out, { stripFrontmatter: true })).toBe(out); + }); + + it('does not open with a frontmatter-shaped fence', () => { + expect(out.startsWith('---')).toBe(false); + }); + + it('has no run of three or more newlines for the canonicaliser to collapse', () => { + expect(out).not.toMatch(/\n{3,}/); + }); +}); + 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. diff --git a/src/lib/docsManifestRoute.test.ts b/src/lib/docsManifestRoute.test.ts index a61df50..e0b5a8e 100644 --- a/src/lib/docsManifestRoute.test.ts +++ b/src/lib/docsManifestRoute.test.ts @@ -194,7 +194,7 @@ describe('GET /api/docs/manifest.json', () => { 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'), + entry('models', '# Models\n\nContenido.\n'), ]); const res = await GET(ctx()); @@ -204,7 +204,9 @@ describe('GET /api/docs/manifest.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); + // Las dos de la colección más la referencia de API, que se genera desde el + // spec y no depende de ella. + expect(body.entries.length).toBe(3); for (const e of body.entries) { expect(e).toHaveProperty('slug'); expect(e).toHaveProperty('contentHash'); @@ -213,4 +215,40 @@ describe('GET /api/docs/manifest.json', () => { expect(e.contentUrl).toBe(`/api/docs/${e.slug}.md`); } }); + + /** + * El bot de Discord no falla cuando un slug desaparece del manifest: borra + * sus chunks en silencio (bot/knowledge.py::load_documentation_from_remote, + * `stale_sources`). Cuando /docs/api pasó a servirse con Scalar, `api` dejó + * de estar en la colección, así que sin esta entrada sintética el bot habría + * perdido la referencia de API sin que saltara ninguna alarma. + */ + it('publishes the API reference even though it is not in the collection', async () => { + getCollectionMock.mockResolvedValue([entry('intro', '# Intro\n\nHola.\n')]); + + const res = await GET(ctx()); + const body = await res.json(); + + const api = body.entries.find((e: { slug: string }) => e.slug === 'api'); + expect(api).toBeDefined(); + expect(api.contentUrl).toBe('/api/docs/api.md'); + expect(api.contentHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(api.title).toBe('API'); + }); + + /** Dos entradas con el mismo slug harían que el bot la indexara dos veces. */ + it('keeps a single `api` entry if the collection ever gets one back', async () => { + getCollectionMock.mockResolvedValue([ + entry('intro', '# Intro\n\nHola.\n'), + entry('api', '# API\n\nUna copia vieja escrita a mano.\n'), + ]); + + const res = await GET(ctx()); + const body = await res.json(); + + const apiEntries = body.entries.filter((e: { slug: string }) => e.slug === 'api'); + expect(apiEntries.length).toBe(1); + // Gana el spec, no el fichero. + expect(apiEntries[0].title).toBe('API'); + }); }); diff --git a/src/lib/openapiSpec.test.ts b/src/lib/openapiSpec.test.ts new file mode 100644 index 0000000..527d4ed --- /dev/null +++ b/src/lib/openapiSpec.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import spec from '../data/openapi.json'; + +/** + * Tripwire sobre src/data/openapi.json, el spec que Scalar renderiza en + * /docs/api y del que sale el Markdown que consume el bot de Discord. + * + * Hace falta porque el spec es un dato, no código: nada lo type-checkea y un + * error dentro se publica en silencio. Y este spec en concreto se derivó del de + * helmcode.com, cuyo catálogo y cuyo modelo de cobro NO son los de NaN, así que + * lo que se vigila sobre todo es que no vuelva a colarse nada de allí. + * + * La superficie de endpoints se comprobó contra el backend real sondeando cada + * ruta: las 12 de aquí responden 401 (existen, piden auth) y /v1/moderations, + * /v1/batches y /v1/files responden 404 (no están habilitadas en NaN). + */ + +const raw = JSON.stringify(spec); + +/** Las 12 rutas públicas verificadas contra api.nan.builders. */ +const PUBLIC_SURFACE: Array<[string, string]> = [ + ['/models', 'get'], + ['/chat/completions', 'post'], + ['/completions', 'post'], + ['/embeddings', 'post'], + ['/rerank', 'post'], + ['/audio/speech', 'post'], + ['/audio/transcriptions', 'post'], + ['/responses', 'post'], + ['/images/generations', 'post'], + ['/images/edits', 'post'], + ['/search', 'post'], + ['/mcp', 'post'], +]; + +/** El catálogo real de NaN (src/data/modelos.json + la referencia de API). */ +const NAN_MODELS = [ + 'deepseek-v4-flash', + 'mimo-v2.5', + 'qwen3.6', + 'gemma4', + 'glm5.2', + 'qwen3-embedding', + 'rerank', + 'kokoro', + 'whisper', + 'flux-2-klein', +]; + +describe('openapi.json — estructura', () => { + it('declara OpenAPI 3.1', () => { + expect(spec.openapi).toMatch(/^3\.1/); + }); + + it('apunta al servidor de NaN', () => { + expect(spec.servers?.[0]?.url).toBe('https://api.nan.builders/v1'); + }); + + it('todos los $ref resuelven', () => { + const missing: string[] = []; + const walk = (node: unknown) => { + if (Array.isArray(node)) return node.forEach(walk); + if (node === null || typeof node !== 'object') return; + for (const [key, value] of Object.entries(node as Record)) { + if (key === '$ref' && typeof value === 'string') { + let cursor: unknown = spec; + for (const part of value.replace(/^#\//, '').split('/')) { + cursor = + cursor && typeof cursor === 'object' + ? (cursor as Record)[part] + : undefined; + } + if (cursor === undefined) missing.push(value); + } else { + walk(value); + } + } + }; + walk(spec); + expect(missing).toEqual([]); + }); +}); + +describe('openapi.json — la superficie es la que el backend sirve de verdad', () => { + for (const [path, method] of PUBLIC_SURFACE) { + it(`documenta ${method.toUpperCase()} ${path}`, () => { + const paths = spec.paths as Record>; + expect(paths[path], path).toBeDefined(); + expect(paths[path][method], `${method} ${path}`).toBeDefined(); + }); + } + + it('no documenta endpoints que NaN no sirve', () => { + const documented = Object.keys(spec.paths as Record); + // Estos tres responden 404 en api.nan.builders. + for (const absent of ['/moderations', '/batches', '/files']) { + expect(documented).not.toContain(absent); + } + expect(documented.length).toBe(PUBLIC_SURFACE.length); + }); +}); + +describe('openapi.json — no queda nada de helmcode.com', () => { + it('no nombra modelos que NaN no sirve', () => { + const foreign = raw.match(/\b(claude-[a-z0-9.-]+|gpt-[0-9][a-z0-9.-]*|gemini-[0-9][a-z0-9.-]*)\b/g); + expect(foreign).toBeNull(); + }); + + it('no describe el cobro por crédito prepago ni la reventa', () => { + for (const term of [ + 'prepaid', + 'credit balance', + 'credits_exhausted', + 'resold', + 'metering_unavailable', + 'subscription_required', + 'ceiling_reached', + ]) { + expect(raw.toLowerCase(), term).not.toContain(term.toLowerCase()); + } + }); + + it('solo menciona Helmcode como la nota del servicio enterprise', () => { + // Una sola mención, y en info: la que dice que la enterprise usa otra base + // URL. Cualquier otra sería marca sin migrar. + const outsideInfo = JSON.stringify({ ...spec, info: undefined }); + expect(outsideInfo.toLowerCase()).not.toContain('helmcode'); + }); +}); + +describe('openapi.json — el catálogo de modelos', () => { + /** + * Los identificadores con pinta de modelo que aparecen en el spec tienen que + * estar todos en el catálogo. Es la red que evita publicar un modelo que no + * existe, que es justo lo que ya pasó una vez con glm5.2 en sentido inverso. + */ + it('no cita ningún identificador de modelo fuera del catálogo', () => { + const cited = new Set( + (raw.match(/`([a-z0-9][a-z0-9.-]{2,})`/g) ?? []) + .map((m) => m.slice(1, -1)) + .filter((token) => /^(deepseek|qwen|gemma|glm|mimo|kokoro|whisper|flux|rerank|claude|gpt|gemini|llama|mistral)/.test(token)), + ); + const unknown = [...cited].filter((m) => !NAN_MODELS.includes(m)); + expect(unknown).toEqual([]); + }); + + it('publica glm5.2 como modelo de chat del tier premium', () => { + const chat = (spec.paths as any)['/chat/completions'].post.requestBody.content[ + 'application/json' + ].schema.properties.model.description as string; + expect(chat).toContain('glm5.2'); + expect(chat).toMatch(/premium tier/i); + }); +}); diff --git a/src/lib/openapiToText.ts b/src/lib/openapiToText.ts new file mode 100644 index 0000000..ffbf20e --- /dev/null +++ b/src/lib/openapiToText.ts @@ -0,0 +1,329 @@ +import { normalizeCanonicalText } from './mdxToText'; + +/** + * Convierte el spec OpenAPI en el Markdown que /api/docs sirve como si fuera + * una página más de la colección. + * + * Existe por el bot de Discord. Cuando la referencia de API dejó de ser + * `api.mdx`, el manifest se habría quedado sin la entrada `api`, y el bot no + * falla ante eso: borra en silencio los chunks de la fuente que desaparece + * (`stale_sources` en bot/knowledge.py::load_documentation_from_remote). Es + * decir, habríamos perdido justo el contenido que más se le pregunta sin que + * saltara ninguna alarma. + * + * Servir el JSON crudo tampoco vale: el bot trocea por PÁRRAFOS en bloques de + * ~2000 caracteres (bot/knowledge.py::chunk_text) y embebe cada trozo. Un + * openapi.json partido cada 2000 caracteres produce fragmentos cortados a + * mitad de llave y embeddings sin sentido. Lo que el bot necesita es prosa en + * párrafos, y de dónde salga esa prosa le da igual. + * + * La salida pasa por normalizeCanonicalText para ser un punto fijo del + * canonicalizador del bot, igual que la de mdxToText: si no lo fuera, su hash + * nunca coincidiría con el nuestro y reindexaría la referencia entera en cada + * versión del manifest. + */ + +/** Sub-conjunto del spec que se lee aquí. El resto se ignora sin molestar. */ +interface Spec { + info?: { title?: string; description?: string; version?: string }; + servers?: Array<{ url?: string; description?: string }>; + tags?: Array<{ name?: string; description?: string }>; + paths?: Record>; + components?: { + schemas?: Record; + responses?: Record; + }; +} + +interface Operation { + operationId?: string; + summary?: string; + description?: string; + tags?: string[]; + deprecated?: boolean; + parameters?: Parameter[]; + requestBody?: { required?: boolean; content?: Record }; + // Una respuesta es un Response Object o un Reference Object a + // #/components/responses: los códigos compartidos (401, 429…) van por $ref. + responses?: Record; + 'x-codeSamples'?: Array<{ lang?: string; label?: string; source?: string }>; +} + +interface ResponseOrRef { + description?: string; + $ref?: string; +} + +interface Parameter { + name?: string; + in?: string; + required?: boolean; + description?: string; + schema?: SchemaLike; +} + +interface MediaType { + schema?: SchemaLike; +} + +interface SchemaLike { + // En OpenAPI 3.1 `type` puede ser una lista: `["string", "null"]` es como se + // declara un campo anulable (finish_reason, entre otros). + type?: string | string[]; + format?: string; + enum?: unknown[]; + default?: unknown; + description?: string; + required?: string[]; + // El valor va como opcional porque un `oneOf` de objetos con claves distintas + // hace que TypeScript infiera `clave?: undefined` en las ramas que no la + // tienen (ContentPart: text | image_url). typeName y describeField ya tratan + // el undefined. + properties?: Record; + items?: SchemaLike; + $ref?: string; + allOf?: SchemaLike[]; + anyOf?: SchemaLike[]; + oneOf?: SchemaLike[]; +} + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options']; + +/** `#/components/schemas/Message` -> `Message`. */ +function refName(ref: string): string { + return ref.split('/').pop() || ref; +} + +/** + * Nombre legible del tipo de un campo, para la columna "Type" de las tablas. + * No resuelve el `$ref`: lo nombra y enlaza mentalmente al esquema, que se + * documenta aparte. Resolverlo aquí expandiría el mismo objeto una vez por + * endpoint y multiplicaría el tamaño del texto que se embebe. + */ +function typeName(schema: SchemaLike | undefined): string { + if (!schema) return 'any'; + if (schema.$ref) return refName(schema.$ref); + if (schema.allOf?.length) return schema.allOf.map(typeName).join(' & '); + if (schema.oneOf?.length) return schema.oneOf.map(typeName).join(' | '); + if (schema.anyOf?.length) return schema.anyOf.map(typeName).join(' | '); + if (Array.isArray(schema.type)) { + return schema.type.join(' | ') || 'any'; + } + if (schema.type === 'array') return `${typeName(schema.items)}[]`; + return schema.type || 'any'; +} + +/** + * Aplana el texto para una celda de tabla: los saltos de línea romperían la + * fila en Markdown y la barra vertical abriría una columna nueva. + */ +function cell(text: string | undefined): string { + if (!text) return ''; + return text.replace(/\r?\n+/g, ' ').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim(); +} + +/** Describe un campo: descripción + enum + default, en una sola celda. */ +function describeField(schema: SchemaLike | undefined, description?: string): string { + const parts: string[] = []; + const desc = description ?? schema?.description; + if (desc) parts.push(cell(desc)); + if (schema?.enum?.length) { + parts.push(`One of: ${schema.enum.map((v) => `\`${String(v)}\``).join(', ')}.`); + } + if (schema?.default !== undefined) parts.push(`Default \`${String(schema.default)}\`.`); + return parts.join(' '); +} + +/** Tabla de propiedades de un objeto. Devuelve '' si no hay ninguna. */ +function propertyTable(schema: SchemaLike | undefined): string { + if (!schema?.properties) return ''; + const required = new Set(schema.required ?? []); + const rows = Object.entries(schema.properties).map(([name, prop]) => { + const req = required.has(name) ? 'required' : 'optional'; + return `| \`${name}\` | ${typeName(prop)} · ${req} | ${describeField(prop)} |`; + }); + if (rows.length === 0) return ''; + return ['| Field | Type | Description |', '| --- | --- | --- |', ...rows].join('\n'); +} + +/** + * Baja de nivel los encabezados que traiga una descripción del spec. + * + * Están escritos para Scalar, que los pinta dentro del panel del endpoint, así + * que empiezan en `##`. Volcados tal cual en un documento plano quedarían por + * encima del `###` del propio endpoint al que pertenecen: el `## Use it as an + * agent tool` de /search se leía como una sección hermana de "Search" en vez de + * como parte suya. Se hunden hasta quedar por debajo, con tope en `######`. + * + * Solo cuentan los encabezados a principio de línea y fuera de un bloque de + * código: dentro de un ``` un `#` suele ser un comentario de shell. + */ +function demoteHeadings(markdown: string, by: number): string { + let inFence = false; + return markdown + .split('\n') + .map((line) => { + if (/^\s*```/.test(line)) { + inFence = !inFence; + return line; + } + if (inFence) return line; + const m = /^(#{1,6})(\s)/.exec(line); + if (!m) return line; + const level = Math.min(6, m[1].length + by); + return `${'#'.repeat(level)}${line.slice(m[1].length)}`; + }) + .join('\n'); +} + +/** + * Descripción de una respuesta, resolviendo el $ref si lo lleva. + * + * Los códigos compartidos (401, 403, 429, 402) están en components.responses y + * cada endpoint los referencia. Sin resolverlos, la tabla de respuestas salía + * con la celda vacía —`| 401 | |`— y quien leyera el texto (o el bot) no se + * enteraba de qué significa cada código en ese endpoint. + */ +function responseDescription(spec: Spec, res: ResponseOrRef | undefined): string { + if (!res) return ''; + if (res.description) return cell(res.description); + if (res.$ref) { + const name = refName(res.$ref); + const target = spec.components?.responses?.[name]; + if (target?.description) return cell(target.description); + return name; + } + return ''; +} + +function renderOperation(spec: Spec, path: string, method: string, op: Operation): string { + const out: string[] = []; + const title = op.summary || op.operationId || `${method.toUpperCase()} ${path}`; + + out.push(`### ${method.toUpperCase()} ${path} — ${title}`); + if (op.deprecated) out.push('**Deprecated.**'); + // La operación es un `###`, así que sus subsecciones bajan a `#####` para no + // competir con los `####` que genera esta misma función (Parameters, etc.). + if (op.description) out.push(demoteHeadings(op.description.trim(), 3)); + + const params = op.parameters ?? []; + if (params.length > 0) { + out.push('#### Parameters'); + out.push( + [ + '| Parameter | In | Type | Description |', + '| --- | --- | --- | --- |', + ...params.map( + (p) => + `| \`${p.name ?? ''}\` | ${p.in ?? ''} | ${typeName(p.schema)} · ${ + p.required ? 'required' : 'optional' + } | ${describeField(p.schema, p.description)} |`, + ), + ].join('\n'), + ); + } + + const bodyContent = op.requestBody?.content ?? {}; + for (const [mediaType, media] of Object.entries(bodyContent)) { + const table = propertyTable(media.schema); + const required = op.requestBody?.required ? 'required' : 'optional'; + out.push(`#### Request body (\`${mediaType}\`, ${required})`); + if (table) { + out.push(table); + } else if (media.schema?.$ref) { + out.push(`See the \`${refName(media.schema.$ref)}\` schema.`); + } + } + + const responses = op.responses ?? {}; + const responseRows = Object.entries(responses).map( + ([status, res]) => `| \`${status}\` | ${responseDescription(spec, res)} |`, + ); + if (responseRows.length > 0) { + out.push('#### Responses'); + out.push(['| Status | Description |', '| --- | --- |', ...responseRows].join('\n')); + } + + for (const sample of op['x-codeSamples'] ?? []) { + if (!sample.source) continue; + out.push(`#### Example — ${sample.label || sample.lang || 'request'}`); + out.push(`\`\`\`${sample.lang || ''}\n${sample.source}\n\`\`\``); + } + + return out.join('\n\n'); +} + +/** Documenta los esquemas reutilizables, que los endpoints solo nombran. */ +function renderSchemas(spec: Spec): string { + const schemas = spec.components?.schemas ?? {}; + const names = Object.keys(schemas).sort(); + if (names.length === 0) return ''; + + const out: string[] = ['## Schemas']; + out.push( + 'Objects reused across endpoints. Where a field above names one of these, this is its shape.', + ); + for (const name of names) { + const schema = schemas[name]; + out.push(`### ${name}`); + if (schema.description) out.push(cell(schema.description)); + const table = propertyTable(schema); + if (table) { + out.push(table); + } else { + out.push(`Type: ${typeName(schema)}.`); + } + } + return out.join('\n\n'); +} + +/** + * El spec entero como Markdown. Se agrupa por tag, en el orden en que el spec + * los declara, que es el orden en el que Scalar los pinta: el texto que lee el + * bot y la página que lee una persona recorren la API en el mismo orden. + */ +export function openapiToText(spec: Spec): string { + const out: string[] = []; + const info = spec.info ?? {}; + + out.push(`# ${info.title ?? 'API'} Reference`); + + const server = spec.servers?.[0]?.url; + if (server) out.push(`Base URL: \`${server}\``); + if (info.description) out.push(info.description.trim()); + + // Operaciones indexadas por tag, conservando el orden de aparición. + const byTag = new Map(); + const tagOrder: string[] = (spec.tags ?? []).map((t) => t.name ?? '').filter(Boolean); + + for (const [path, item] of Object.entries(spec.paths ?? {})) { + for (const method of HTTP_METHODS) { + const op = item?.[method]; + if (!op) continue; + const tag = op.tags?.[0] || 'Endpoints'; + if (!byTag.has(tag)) { + byTag.set(tag, []); + if (!tagOrder.includes(tag)) tagOrder.push(tag); + } + byTag.get(tag)!.push(renderOperation(spec, path, method, op)); + } + } + + const tagDescriptions = new Map( + (spec.tags ?? []).map((t) => [t.name ?? '', t.description ?? '']), + ); + + for (const tag of tagOrder) { + const ops = byTag.get(tag); + if (!ops || ops.length === 0) continue; + out.push(`## ${tag}`); + const desc = tagDescriptions.get(tag); + if (desc) out.push(desc); + out.push(...ops); + } + + const schemas = renderSchemas(spec); + if (schemas) out.push(schemas); + + return normalizeCanonicalText(out.join('\n\n')); +} diff --git a/src/pages/api/docs/[slug].md.ts b/src/pages/api/docs/[slug].md.ts index 5d5b6d2..934da30 100644 --- a/src/pages/api/docs/[slug].md.ts +++ b/src/pages/api/docs/[slug].md.ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; import { getEntry } from 'astro:content'; import { env } from 'cloudflare:workers'; +import { API_DOC_SLUG, getApiDocText } from '../../../lib/apiDoc'; import { sha256Hex } from '../../../lib/contentHash'; import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; import { mdxToText } from '../../../lib/mdxToText'; @@ -16,12 +17,19 @@ export const GET: APIRoute = async ({ params, request }) => { } try { - const entry = await getEntry('docs', slug); - if (!entry) { - return new Response('Not found', { status: 404 }); + // `api` no está en la colección desde que Scalar sirve la referencia: su + // cuerpo se genera desde el spec. Ver src/lib/apiDoc.ts. + let body: string; + if (slug === API_DOC_SLUG) { + body = getApiDocText(); + } else { + const entry = await getEntry('docs', slug); + if (!entry) { + return new Response('Not found', { status: 404 }); + } + body = await mdxToText(entry.body ?? '', getRateLimitsConfig(env)); } - const body = await mdxToText(entry.body ?? '', getRateLimitsConfig(env)); const contentHash = `sha256:${await sha256Hex(body)}`; const etag = quoteEtag(contentHash); diff --git a/src/pages/api/docs/manifest.json.ts b/src/pages/api/docs/manifest.json.ts index 2df1391..c7d31f5 100644 --- a/src/pages/api/docs/manifest.json.ts +++ b/src/pages/api/docs/manifest.json.ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; import { getCollection } from 'astro:content'; import { env } from 'cloudflare:workers'; +import { API_DOC_META, API_DOC_SLUG, getApiDocText } from '../../../lib/apiDoc'; import { sha256Hex } from '../../../lib/contentHash'; import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; import { mdxToText } from '../../../lib/mdxToText'; @@ -22,21 +23,44 @@ export const GET: APIRoute = async ({ request }) => { }); 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`, - }; - }), + // Si alguien vuelve a añadir un `api.md(x)` a la colección, el spec sigue + // mandando: dos entradas con el mismo slug le harían indexar la referencia + // dos veces, y con contenidos distintos. + const collectionEntries = await Promise.all( + entries + .filter((entry) => entry.id !== API_DOC_SLUG) + .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`, + }; + }), ); + // La referencia de API ya no es un fichero de la colección: la sirve Scalar + // desde el spec. Se publica igualmente como entrada, porque para quien + // consume el manifest sigue siendo una página de docs más. Ver src/lib/apiDoc.ts. + const apiText = getApiDocText(); + const apiEntry = { + slug: API_DOC_SLUG, + title: API_DOC_META.title, + description: API_DOC_META.description, + order: API_DOC_META.order, + contentHash: `sha256:${await sha256Hex(apiText)}`, + contentUrl: `/api/docs/${API_DOC_SLUG}.md`, + }; + + const manifestEntries = [...collectionEntries, apiEntry].sort((a, b) => { + if (a.order !== b.order) return a.order - b.order; + return a.slug.localeCompare(b.slug); + }); + const versionSeed = JSON.stringify(manifestEntries.map((e) => [e.slug, e.contentHash])); const version = `sha256:${await sha256Hex(versionSeed)}`; const etag = quoteEtag(version); diff --git a/src/pages/docs/api.astro b/src/pages/docs/api.astro new file mode 100644 index 0000000..a33b09f --- /dev/null +++ b/src/pages/docs/api.astro @@ -0,0 +1,11 @@ +--- +/* + * /docs/api — la referencia interactiva. + * + * Ruta estática, así que gana al catch-all `[...slug].astro` que sirve el resto + * de la colección de docs. `api.mdx` ya no existe: el contenido sale del spec. + */ +import ApiReference from '../../components/docs/ApiReference.astro'; +--- + + diff --git a/src/pages/es/docs/api.astro b/src/pages/es/docs/api.astro new file mode 100644 index 0000000..2ef1785 --- /dev/null +++ b/src/pages/es/docs/api.astro @@ -0,0 +1,13 @@ +--- +/* + * /es/docs/api — misma referencia con el chrome y las etiquetas de Scalar en + * español. El contenido del spec se queda en inglés, por convención de las + * referencias de API. + * + * Es la única página de /docs con versión en español: las guías de la colección + * siguen solo en inglés. + */ +import ApiReference from '../../../components/docs/ApiReference.astro'; +--- + + diff --git a/src/pages/openapi.json.ts b/src/pages/openapi.json.ts new file mode 100644 index 0000000..e40783d --- /dev/null +++ b/src/pages/openapi.json.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from 'astro'; +import spec from '../data/openapi.json'; + +/** + * El spec OpenAPI que renderiza Scalar en /docs/api. + * + * Vive en src/data/ y no en public/ para que haya UNA sola copia: el generador + * de Markdown que alimenta /api/docs (src/lib/openapiToText.ts) lo importa del + * mismo sitio. Con el fichero en public/ habría que duplicarlo o leerlo del + * disco, y en Workers no hay disco. + * + * Prerenderizado: es contenido estático que cambia solo cuando se despliega, y + * así Scalar lo pide a un asset en vez de despertar al worker en cada visita. + */ +export const prerender = true; + +export const GET: APIRoute = () => + new Response(JSON.stringify(spec), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + }, + }); diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts index dd9a250..2cc20c4 100644 --- a/src/pages/sitemap.xml.ts +++ b/src/pages/sitemap.xml.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { getCollection } from 'astro:content'; +import { API_DOC_SLUG } from '../lib/apiDoc'; import { LOCALES, withLang, type Locale } from '../lib/i18n'; export const prerender = false; @@ -75,6 +76,21 @@ export const GET: APIRoute = async () => { entries.push(`${abs(`/docs/${doc.id}`)}`); } + /* + * La referencia de API va aparte porque ya no sale de la colección: la sirve + * Scalar desde el spec. Y sí lleva alternates, porque es la única página de + * /docs que existe en los dos idiomas — el spec se queda en inglés, pero el + * chrome y las etiquetas de Scalar están traducidos y /es/docs/api es una URL + * real que queremos indexada como tal. + */ + const apiAlternates = LOCALES.map((lang) => ({ + lang, + href: abs(withLang(`/docs/${API_DOC_SLUG}`, lang)), + })); + for (const { href } of apiAlternates) { + entries.push(urlEntry(href.replace(SITE, ''), apiAlternates)); + } + const xml = `` + `` + diff --git a/src/styles/scalar-theme.css b/src/styles/scalar-theme.css new file mode 100644 index 0000000..2246173 --- /dev/null +++ b/src/styles/scalar-theme.css @@ -0,0 +1,89 @@ +/* ============================================ + Scalar API Reference — tema de NaN + ============================================ + + Scalar se tematiza entero con variables CSS (--scalar-*). Aquí se mapean a + los tokens de src/styles/tokens.css, que es la fuente de verdad del sistema: + ningún hex suelto en este fichero. En la versión de helmcode ese cableado se + quedó pendiente y el tema heredó literales; aquí se hace desde el principio, + así que cambiar el violeta en tokens.css cambia también la referencia de API. + + El sistema de NaN es SOLO modo oscuro (tokens.css), así que no se define + `.light-mode`: el componente fuerza el modo oscuro y oculta el conmutador. + Referencia de variables: https://scalar.com/products/api-references/themes */ + +@import './tokens.css'; + +:root { + --scalar-font: var(--font-sans); + --scalar-font-code: var(--font-mono); + + /* Esquina recta en toda la UI, igual que el resto del sitio. */ + --scalar-radius: var(--radius-ui); + --scalar-radius-lg: var(--radius-ui); + --scalar-radius-xl: var(--radius-ui); +} + +/* Scalar aplica el tema bajo .dark-mode. El componente lo fuerza siempre. */ +.dark-mode { + --scalar-background-1: var(--color-bg); /* base */ + --scalar-background-2: var(--color-surface); /* elevado — cards, bloques de código */ + --scalar-background-3: #1B1B1F; /* hovers y anidados: un escalón sobre --color-surface */ + --scalar-background-accent: var(--color-violet-dim); + + /* Rampa de texto: los tres niveles del sistema, sin inventar grises nuevos. */ + --scalar-color-1: var(--color-text); /* titulares, nombres de propiedad */ + --scalar-color-2: var(--color-body); /* prosa y descripciones */ + --scalar-color-3: var(--color-muted); /* metadatos, pistas */ + + /* El acento va con --color-violet-2 y no con --color-violet: Scalar lo usa + sobre todo en TEXTO (enlaces, pestaña activa, nombres de operación), y la + regla de contraste de tokens.css reserva --color-violet para rellenos por + quedarse en 3.43:1. El claro cumple AA (5.39:1) sobre el off-black. */ + --scalar-color-accent: var(--color-violet-2); + --scalar-border-color: var(--color-line); + + /* Paleta semántica: pastillas de método HTTP, badges de tipo y resaltado de + sintaxis. Es la única parte que NO puede colapsar al monoacento — un POST + y un DELETE tienen que distinguirse entre sí. */ + --scalar-color-green: #3FB950; + --scalar-color-red: #F85149; + --scalar-color-yellow: #E3B341; + --scalar-color-blue: #58A6FF; + --scalar-color-orange: #FF9A4D; + --scalar-color-purple: var(--color-violet-2); +} + +/* ── Jerarquía por brillo y peso, no por tono ──────────────────────────────── + El nombre de propiedad lee fuerte y en peso medio en vez de ir coloreado, y + los tipos se quedan apagados. Así el violeta sigue significando una sola + cosa en todo el sitio: interactivo. Las clases son de Scalar. */ +.property .property-name, +.property-name { + color: var(--scalar-color-1); + font-weight: 600; +} +.property-detail-value { + color: var(--scalar-color-3); +} + +/* OJO: no forzar `height: 100vh` en #scalar-api-reference / .scalar-app. Scalar + dimensiona su propio layout (100dvh + --refs-sidebar-height, grid CSS) y + constreñirlo rompe la sincronía de scroll del sidebar: los enlaces dejan de + responder. El layout es suyo. */ + +/* ── Funciones de producto alojado ─────────────────────────────────────────── + "Ask AI" y "Generate MCP" apuntan a la nube de Scalar. NO se ocultan aquí: + se apagan por configuración en ApiReference.astro (`agent.disabled` y + `mcp.disabled`), porque ocultarlas por CSS no evitaba que el componente se + montara y llamara a api.scalar.com en cada carga. + + La atribución "Powered by Scalar" se MANTIENE a propósito: Scalar es MIT y + el crédito es suyo. */ + +/* El sitio es solo oscuro, así que el conmutador de color no tiene sentido: + dejarlo visible ofrece un modo claro que el resto del sitio no tiene. */ +.scalar-app .darklight, +.scalar-app .darklight-reference { + display: none !important; +} diff --git a/src/tests/lib/docsGlm52.test.ts b/src/tests/lib/docsGlm52.test.ts index 4b41ec7..5b456b1 100644 --- a/src/tests/lib/docsGlm52.test.ts +++ b/src/tests/lib/docsGlm52.test.ts @@ -2,16 +2,22 @@ import { beforeAll, describe, expect, test } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; +import { getApiDocText } from '../../lib/apiDoc'; import { mdxToText } from '../../lib/mdxToText'; /** - * What docs/models#glm-5-2 and docs/api#rate-limits publish about glm5.2. + * What docs/models#glm-5-2 and the API reference publish about glm5.2. * * Both pages described a model that does not exist: "not available yet", * "requests naming it return a model error" and a 256K context, while the * model was being served in production and a premium member was using it. The * asserts here are on the text /api/docs actually serves (same extractor the * Discord bot consumes), so the page and the API are covered at once. + * + * Desde la migración a Scalar, la mitad de `api` ya no sale de `api.mdx` sino + * del spec (src/data/openapi.json) a través de openapiToText. La redacción + * cambió con ella; lo que se comprueba sigue siendo lo mismo: que glm5.2 se + * publique como llamable, con sus límites reales y sin decir de dónde sale. */ const here = dirname(fileURLToPath(import.meta.url)); @@ -27,7 +33,7 @@ let api = ''; beforeAll(async () => { models = await mdxToText(docBody('models')); - api = await mdxToText(docBody('api')); + api = getApiDocText(); }); /** The glm5.2 card only, so a 256K from another model cannot pass for it. */ @@ -81,34 +87,31 @@ describe('docs/models — the glm5.2 card', () => { }); describe('docs/api — glm5.2 is callable', () => { - test('is listed among the compatible chat models', () => { - const line = api.split('\n').find((l) => l.includes('Compatible models:')); - expect(line).toBeDefined(); - expect(line).toContain('`glm5.2`'); + test('is listed among the chat models the `model` field accepts', () => { + const row = api.split('\n').find((l) => /^\|\s*`model`\s*\|/.test(l)); + expect(row).toBeDefined(); + expect(row).toContain('`glm5.2`'); }); test('no surface still says it is coming soon or not callable', () => { expect(api).not.toMatch(/coming soon/i); expect(api).not.toMatch(/not callable/i); + expect(api).not.toMatch(/not available yet/i); }); - test('the model field accepts it', () => { - // The extractor pads the table columns, hence the loose left edge. - const row = api.split('\n').find((l) => /^\|\s*`model`\s*\|/.test(l)); + test('the model catalog carries its context and the tier it needs', () => { + const row = api.split('\n').find((l) => /^\|\s*`glm5\.2`\s*\|/.test(l)); expect(row).toBeDefined(); - expect(row).toContain('glm5.2'); - }); - - test('the rate limits block carries the four limits', () => { - expect(api).toContain('Rolling 4h window: 400M tokens'); - expect(api).toContain('Allowance / billing period: 3,000M tokens'); - expect(api).toContain('Context window: 500K tokens'); - expect(api).toContain('Concurrent requests: 5'); + expect(row).toContain('500K-token context'); + expect(row).toMatch(/premium tier/i); + expect(row).not.toContain('256K'); }); test('the 4h window is spelled out, not left as small print', () => { expect(api).toContain('400M tokens per rolling 4 hours'); - expect(api).toMatch(/rolling window, not a daily reset/); + expect(api).toContain('3,000M-token allowance'); + expect(api).toMatch(/rolling, not a daily reset/); + expect(api).toMatch(/billing period starts/); }); test('the errors table documents the statuses the limits return', () => { @@ -117,11 +120,21 @@ describe('docs/api — glm5.2 is callable', () => { const section = api.slice(api.indexOf('## Errors')); expect(section.length).toBeGreaterThan(0); const rows = section.split('\n').filter((l) => l.startsWith('|')); - const status = (code: string) => rows.find((l) => new RegExp(`^\\|\\s*${code}\\s*\\|`).test(l)); - expect(status('402')).toMatch(/Token allowance for the billing period exhausted/); + const status = (code: string) => + rows.find((l) => new RegExp(`^\\|\\s*\`${code}\`\\s*\\|`).test(l)); + expect(status('402')).toMatch(/token allowance for the billing period is spent/i); expect(status('402')).toContain('monthly_cap_reached'); expect(status('429')).toMatch(/rolling 4h token budget of `glm5\.2`/); }); + + /** + * El 402 dejó de ser el crédito prepago de helmcode. Si al reconstruir el + * spec se recupera aquel texto, volvería a publicarse un modelo de cobro que + * NaN no tiene. + */ + test('does not describe a prepaid-credit billing model', () => { + expect(api).not.toMatch(/prepaid|credit balance|credits_exhausted|top ?up/i); + }); }); describe('no published surface reveals how glm5.2 is sourced', () => { diff --git a/src/tests/lib/sitemap.test.ts b/src/tests/lib/sitemap.test.ts index ab02d4e..e19802c 100644 --- a/src/tests/lib/sitemap.test.ts +++ b/src/tests/lib/sitemap.test.ts @@ -118,7 +118,31 @@ describe('sitemap.xml', () => { test('sin documentos, el sitemap sigue siendo válido', async () => { const { xml } = await sitemap([]); expect(xml).toContain(' u.includes('/docs/'))).toEqual([ + `${SITE}/docs/api`, + `${SITE}/es/docs/api`, + ]); + }); + + /** + * /docs/api dejó de salir de la colección: la sirve Scalar desde el spec. Es + * además la única página de /docs que existe en español, así que es la única + * de la sección que lleva alternates. Si algún día se vuelve a colar en la + * colección, saldría dos veces — lo cubre el test de URLs repetidas. + */ + test('la referencia de API entra aunque no esté en la colección, y con sus dos idiomas', async () => { + const { xml } = await sitemap([{ id: 'intro' }]); + const urls = locs(xml); + + expect(urls).toContain(`${SITE}/docs/api`); + expect(urls).toContain(`${SITE}/es/docs/api`); + + const block = xml.match(/https:\/\/nan\.builders\/docs\/api<\/loc>(.*?)<\/url>/)?.[1] ?? ''; + expect(block).toContain(`hreflang="en" href="${SITE}/docs/api"`); + expect(block).toContain(`hreflang="es" href="${SITE}/es/docs/api"`); + expect(block).toContain(`hreflang="x-default" href="${SITE}/docs/api"`); }); test('no hay URLs repetidas', async () => { From c5beaf2546efa8903421244155f974f60dac6a8c Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Wed, 12 Aug 2026 12:12:28 +0200 Subject: [PATCH 02/15] docs(api): drop enhance_prompt, which NaN documents nowhere Systematic comparison of the new spec against the api.mdx it replaces, which was NaN's previous source of truth. Everything else lines up: the fields per endpoint, the model enums (/completions qwen3.6, /responses qwen3.6+gemma4 and so on), the reasoning mapping (reasoning_effort on deepseek-v4-flash, chat_template_kwargs.enable_thinking on qwen3.6/gemma4) and the defaults. enhance_prompt was the exception: /images/generations and /images/edits declared it as a "NaN extension", wording that came out of mechanically replacing "Helmcode extension", and api.mdx never mentions it, unlike seed and guidance which are documented and stay. LiteLLM's spec does not know it either, though it declares no body for that endpoint so that is not conclusive. With no evidence it exists on NaN it goes: the issue's criterion is to document nothing the backend does not serve. If it turns out to exist, adding it back is one line. --- src/data/openapi.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/data/openapi.json b/src/data/openapi.json index e396acd..6de2e19 100644 --- a/src/data/openapi.json +++ b/src/data/openapi.json @@ -1212,11 +1212,6 @@ "guidance": { "type": "number", "description": "NaN extension: FLUX guidance scale (in OpenAI SDKs, pass via `extra_body`)." - }, - "enhance_prompt": { - "type": "boolean", - "default": false, - "description": "NaN extension: when `true`, an AI model first rewrites your prompt into a richer, more detailed description before generation — enriching style, lighting and composition without adding new subjects. Defaults to `false` on the API (in OpenAI SDKs, pass via `extra_body`). The rewritten prompt is used internally and is not returned in the response." } } } @@ -1345,11 +1340,6 @@ "guidance": { "type": "number", "description": "NaN extension: FLUX guidance scale (form field)." - }, - "enhance_prompt": { - "type": "boolean", - "default": false, - "description": "NaN extension: when `true`, an AI model first rewrites your prompt into a richer description before generation (subjects unchanged). Send as a form field; defaults to `false`. The rewritten prompt is used internally and is not returned." } } } From 4c8f49dd6ce25c68d46d6395f1fc000ce6aec36d Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Wed, 12 Aug 2026 12:12:28 +0200 Subject: [PATCH 03/15] docs(api): gemma4 does have tool calling, verified against the API Checked with a real key: gemma4 answers with finish_reason `tool_calls` and emits get_weather({"city": "Madrid"}) given a prompt that asks for it. It also accepts image input. The model table came from api.mdx, which credited it with vision and reasoning only. modelos.json did list it with tool calling: of the two sources that contradicted each other, modelos.json was right on this point. qwen3.6 stays as it was, which was correct: it emits tool calls and accepts images, exactly as already published. --- src/data/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/openapi.json b/src/data/openapi.json index 6de2e19..a963b85 100644 --- a/src/data/openapi.json +++ b/src/data/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "NaN API", "version": "1.0.0", - "description": "Open models on a shared EU inference cluster. Zero logs.\n\nThe NaN API is OpenAI-compatible: predictable, resource-oriented URLs, JSON request and response bodies, and standard HTTP verbs and status codes. Point any OpenAI SDK at our base URL and your existing code keeps working. Change the base URL and the API key — that's it.\n\nOne schema across every model, so you only learn the API once. Change the `model` field to switch models; everything else stays the same.\n\n- Base URL: `https://api.nan.builders/v1`\n- OpenAPI spec: this document. Import it into Postman, Insomnia, or your own tooling.\n\nIf you use the [Helmcode](https://helmcode.com) enterprise service, the base URL is `https://api.helmcode.com/v1` instead. Every other endpoint is identical.\n\n## Authentication\n\nEvery request authenticates with an API key, sent as a Bearer token:\n\n```\nAuthorization: Bearer $NAN_API_KEY\n```\n\nYou must be a NaN community member. Generate your key from user settings, under \"API Keys\", on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. Keep it secret: never embed one in client-side code or commit it to source control. Requests must go over HTTPS; calls over plain HTTP fail.\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"qwen3.6\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. Only `/chat/completions` streams incrementally — `/responses` currently emits a single terminal event.\n\n## Rate limits\n\nLimits apply per API key — RPM and concurrency — not on total token volume:\n\n| Limit | Value |\n| --- | --- |\n| Requests per minute | 60 |\n| Concurrent requests | 5 |\n| Tokens per minute (`deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4`) | 1.5M |\n| Requests per minute (`rerank`) | 1,000 |\n\n`glm5.2` is not gated by a per-minute rate but by a rolling window plus an allowance per billing period: 400M tokens per rolling 4 hours and a 3,000M-token allowance that returns to zero when your billing period starts. The window is rolling, not a daily reset.\n\nWeb search runs on its own budget, separate from the model endpoints: 20 requests per minute, 3 concurrent, and 500 searches per day per key. Image endpoints have their own too: 20 requests per minute and 100 requests per month. Exceed any limit and you get a `429`.\n\n## Errors\n\nNaN uses conventional HTTP status codes: `2xx` on success, `4xx` for a problem with the request — a missing parameter, an invalid key, an unavailable model — and `5xx` for a server-side error. Every error returns a JSON body in the OpenAI shape:\n\n```json\n{\n \"error\": {\n \"message\": \"The model 'foo' does not exist.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"model\",\n \"code\": \"model_not_found\"\n }\n}\n```\n\n`message` is human-readable, `param` names the offending field when applicable, and `code` is a short machine-readable string you can branch on.\n\n| Status | Meaning | `code` |\n| --- | --- | --- |\n| `400` | Invalid or malformed parameter (`param` says which); or content blocked by the safety filter. | `invalid_request_error` · `content_policy_violation` |\n| `401` | Missing or invalid API key. | `invalid_api_key` |\n| `402` | The token allowance for the billing period is spent on a model that carries one, such as `glm5.2`. Not retryable: the counter returns to zero when your billing period starts. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint or model. Image generation requires inference membership. | `tier_restricted` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.2`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | — |\n| `503` | Web search is temporarily unavailable; retry shortly. | `search_unavailable` |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | — |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly: they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either — it clears when your billing period starts.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, reasoning | Streaming, tool calling, reasoning, 1M-token context. 500M tokens/month per member |\n| `mimo-v2.5` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision | Streaming, vision, reasoning (opt-in) |\n| `glm5.2` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, 500K-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n\n`glm5.2` is served only to keys on the GLM 5.2 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints — we add capabilities without breaking existing fields.", + "description": "Open models on a shared EU inference cluster. Zero logs.\n\nThe NaN API is OpenAI-compatible: predictable, resource-oriented URLs, JSON request and response bodies, and standard HTTP verbs and status codes. Point any OpenAI SDK at our base URL and your existing code keeps working. Change the base URL and the API key — that's it.\n\nOne schema across every model, so you only learn the API once. Change the `model` field to switch models; everything else stays the same.\n\n- Base URL: `https://api.nan.builders/v1`\n- OpenAPI spec: this document. Import it into Postman, Insomnia, or your own tooling.\n\nIf you use the [Helmcode](https://helmcode.com) enterprise service, the base URL is `https://api.helmcode.com/v1` instead. Every other endpoint is identical.\n\n## Authentication\n\nEvery request authenticates with an API key, sent as a Bearer token:\n\n```\nAuthorization: Bearer $NAN_API_KEY\n```\n\nYou must be a NaN community member. Generate your key from user settings, under \"API Keys\", on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. Keep it secret: never embed one in client-side code or commit it to source control. Requests must go over HTTPS; calls over plain HTTP fail.\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"qwen3.6\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. Only `/chat/completions` streams incrementally — `/responses` currently emits a single terminal event.\n\n## Rate limits\n\nLimits apply per API key — RPM and concurrency — not on total token volume:\n\n| Limit | Value |\n| --- | --- |\n| Requests per minute | 60 |\n| Concurrent requests | 5 |\n| Tokens per minute (`deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4`) | 1.5M |\n| Requests per minute (`rerank`) | 1,000 |\n\n`glm5.2` is not gated by a per-minute rate but by a rolling window plus an allowance per billing period: 400M tokens per rolling 4 hours and a 3,000M-token allowance that returns to zero when your billing period starts. The window is rolling, not a daily reset.\n\nWeb search runs on its own budget, separate from the model endpoints: 20 requests per minute, 3 concurrent, and 500 searches per day per key. Image endpoints have their own too: 20 requests per minute and 100 requests per month. Exceed any limit and you get a `429`.\n\n## Errors\n\nNaN uses conventional HTTP status codes: `2xx` on success, `4xx` for a problem with the request — a missing parameter, an invalid key, an unavailable model — and `5xx` for a server-side error. Every error returns a JSON body in the OpenAI shape:\n\n```json\n{\n \"error\": {\n \"message\": \"The model 'foo' does not exist.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"model\",\n \"code\": \"model_not_found\"\n }\n}\n```\n\n`message` is human-readable, `param` names the offending field when applicable, and `code` is a short machine-readable string you can branch on.\n\n| Status | Meaning | `code` |\n| --- | --- | --- |\n| `400` | Invalid or malformed parameter (`param` says which); or content blocked by the safety filter. | `invalid_request_error` · `content_policy_violation` |\n| `401` | Missing or invalid API key. | `invalid_api_key` |\n| `402` | The token allowance for the billing period is spent on a model that carries one, such as `glm5.2`. Not retryable: the counter returns to zero when your billing period starts. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint or model. Image generation requires inference membership. | `tier_restricted` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.2`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | — |\n| `503` | Web search is temporarily unavailable; retry shortly. | `search_unavailable` |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | — |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly: they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either — it clears when your billing period starts.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, reasoning | Streaming, tool calling, reasoning, 1M-token context. 500M tokens/month per member |\n| `mimo-v2.5` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision, agents | Streaming, tool calling, vision, reasoning (opt-in) |\n| `glm5.2` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, 500K-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n\n`glm5.2` is served only to keys on the GLM 5.2 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints — we add capabilities without breaking existing fields.", "contact": { "name": "NaN", "url": "https://nan.builders" From 81b9703f5b916d169596fb9408edbe9ca5364aaa Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Wed, 12 Aug 2026 12:12:28 +0200 Subject: [PATCH 04/15] fix(docs): the brand fonts were never loaded on /docs/api The page declared `--scalar-font: var(--font-sans)` (Archivo) but never imported the fonts, so the browser had nowhere to get them from and fell back to the system one. Zero .woff2 files requested. It is the kind of fault code review does not catch: the CSS variables resolved correctly and only the rendering was wrong. It surfaced by checking in the browser which font the body actually used. They are imported here the way Docs.astro and NanBase.astro do. Leaving them to Scalar is not an option: it would serve them from fonts.scalar.com, which is exactly what `withDefaultFonts: false` avoids. --- src/components/docs/ApiReference.astro | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/docs/ApiReference.astro b/src/components/docs/ApiReference.astro index d7a5efd..c034b5e 100644 --- a/src/components/docs/ApiReference.astro +++ b/src/components/docs/ApiReference.astro @@ -14,6 +14,18 @@ * enlaces normales, como el resto del sitio (Nav.astro), no con el intercambio * en caliente que se montó en helmcode. */ +/* + * Fuentes autohospedadas, igual que Docs.astro y NanBase.astro. Sin esto la + * página declara `--scalar-font: var(--font-sans)` (Archivo) pero el navegador + * no tiene de dónde sacarla y cae al sistema: se veía correcta en las variables + * CSS y mal en pantalla. No se pueden dejar a Scalar, que las serviría desde + * fonts.scalar.com (ver `withDefaultFonts: false` más abajo). + */ +import '@fontsource-variable/archivo/wdth.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/500.css'; +import '@fontsource/jetbrains-mono/700.css'; + import { switchLocalePath, type Locale } from '../../lib/i18n'; interface Props { From 31a23dddae6f8ed9ac5d99a860a2028b31a159e1 Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Wed, 12 Aug 2026 12:12:28 +0200 Subject: [PATCH 05/15] style(docs): no em dashes, and src/lib comments in English Two cleanups with no functional effect. Em dashes are removed from everything written on this branch: code, the published spec and the messages. Replaced by colons, commas or parentheses as each sentence called for, not by a bare hyphen. Pre-existing content that did not come from here is left as it was. And the comment language: the repo has two conventions and they had been mixed. The /api/docs pipeline is in English (rateLimits.ts, mdxToText.ts and their tests) and the layouts, styles and middleware in Spanish. The new files under src/lib came out in Spanish, and there was even a Spanish block inside canonicalParity.test.ts, which is entirely English. They move to English; ApiReference.astro and scalar-theme.css stay Spanish, like their neighbours. --- src/components/docs/ApiReference.astro | 8 +- src/data/openapi.json | 16 ++-- src/lib/apiDoc.ts | 26 +++--- src/lib/canonicalParity.test.ts | 10 +-- src/lib/docsManifestRoute.test.ts | 18 ++-- src/lib/openapiSpec.test.ts | 72 ++++++++-------- src/lib/openapiToText.ts | 112 ++++++++++++------------- src/pages/api/docs/[slug].md.ts | 4 +- src/pages/api/docs/manifest.json.ts | 12 +-- src/pages/docs/api.astro | 2 +- src/pages/es/docs/api.astro | 2 +- src/pages/sitemap.xml.ts | 2 +- src/styles/scalar-theme.css | 6 +- src/tests/lib/docsGlm52.test.ts | 14 ++-- src/tests/lib/sitemap.test.ts | 2 +- 15 files changed, 156 insertions(+), 150 deletions(-) diff --git a/src/components/docs/ApiReference.astro b/src/components/docs/ApiReference.astro index c034b5e..fc8f947 100644 --- a/src/components/docs/ApiReference.astro +++ b/src/components/docs/ApiReference.astro @@ -1,6 +1,6 @@ --- /* - * ApiReference — la referencia de API interactiva de /docs/api, servida con + * ApiReference: la referencia de API interactiva de /docs/api, servida con * Scalar desde src/data/openapi.json en vez de escrita a mano en MDX. * * Es una página COMPLETA, no un componente dentro de Docs.astro: Scalar trae su @@ -42,7 +42,7 @@ const t = { ref: 'API Reference', platform: 'Platform ↗', site: 'nan.builders ↗', - title: 'API Reference — NaN Docs', + title: 'API Reference · NaN Docs', description: 'Interactive reference for the NaN API: OpenAI-compatible endpoints for chat, embeddings, rerank, audio, images and web search.', }, @@ -52,7 +52,7 @@ const t = { ref: 'Referencia API', platform: 'Plataforma ↗', site: 'nan.builders ↗', - title: 'Referencia API — NaN Docs', + title: 'Referencia API · NaN Docs', description: 'Referencia interactiva de la API de NaN: endpoints compatibles con OpenAI para chat, embeddings, rerank, audio, imágenes y búsqueda web.', }, @@ -66,7 +66,7 @@ const ogImage = `${SITE}/og/og-${lang}.png`; * `connect-src` tiene que dejar salir a api.nan.builders o el "try it" no * funciona: el navegador manda la petición desde esta página al dominio de la * API. Va directo a propósito, sin el proxy en la nube de Scalar (`proxyUrl: - * ''` abajo) — mandar la key de un miembro a un tercero contradice el "zero + * ''` abajo): mandar la key de un miembro a un tercero contradice el "zero * logs" del producto. La API responde con CORS abierto, así que no hace falta * intermediario. `worker-src blob:` es para el resaltado de sintaxis de Scalar. */ diff --git a/src/data/openapi.json b/src/data/openapi.json index a963b85..43a87fb 100644 --- a/src/data/openapi.json +++ b/src/data/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "NaN API", "version": "1.0.0", - "description": "Open models on a shared EU inference cluster. Zero logs.\n\nThe NaN API is OpenAI-compatible: predictable, resource-oriented URLs, JSON request and response bodies, and standard HTTP verbs and status codes. Point any OpenAI SDK at our base URL and your existing code keeps working. Change the base URL and the API key — that's it.\n\nOne schema across every model, so you only learn the API once. Change the `model` field to switch models; everything else stays the same.\n\n- Base URL: `https://api.nan.builders/v1`\n- OpenAPI spec: this document. Import it into Postman, Insomnia, or your own tooling.\n\nIf you use the [Helmcode](https://helmcode.com) enterprise service, the base URL is `https://api.helmcode.com/v1` instead. Every other endpoint is identical.\n\n## Authentication\n\nEvery request authenticates with an API key, sent as a Bearer token:\n\n```\nAuthorization: Bearer $NAN_API_KEY\n```\n\nYou must be a NaN community member. Generate your key from user settings, under \"API Keys\", on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. Keep it secret: never embed one in client-side code or commit it to source control. Requests must go over HTTPS; calls over plain HTTP fail.\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"qwen3.6\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. Only `/chat/completions` streams incrementally — `/responses` currently emits a single terminal event.\n\n## Rate limits\n\nLimits apply per API key — RPM and concurrency — not on total token volume:\n\n| Limit | Value |\n| --- | --- |\n| Requests per minute | 60 |\n| Concurrent requests | 5 |\n| Tokens per minute (`deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4`) | 1.5M |\n| Requests per minute (`rerank`) | 1,000 |\n\n`glm5.2` is not gated by a per-minute rate but by a rolling window plus an allowance per billing period: 400M tokens per rolling 4 hours and a 3,000M-token allowance that returns to zero when your billing period starts. The window is rolling, not a daily reset.\n\nWeb search runs on its own budget, separate from the model endpoints: 20 requests per minute, 3 concurrent, and 500 searches per day per key. Image endpoints have their own too: 20 requests per minute and 100 requests per month. Exceed any limit and you get a `429`.\n\n## Errors\n\nNaN uses conventional HTTP status codes: `2xx` on success, `4xx` for a problem with the request — a missing parameter, an invalid key, an unavailable model — and `5xx` for a server-side error. Every error returns a JSON body in the OpenAI shape:\n\n```json\n{\n \"error\": {\n \"message\": \"The model 'foo' does not exist.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"model\",\n \"code\": \"model_not_found\"\n }\n}\n```\n\n`message` is human-readable, `param` names the offending field when applicable, and `code` is a short machine-readable string you can branch on.\n\n| Status | Meaning | `code` |\n| --- | --- | --- |\n| `400` | Invalid or malformed parameter (`param` says which); or content blocked by the safety filter. | `invalid_request_error` · `content_policy_violation` |\n| `401` | Missing or invalid API key. | `invalid_api_key` |\n| `402` | The token allowance for the billing period is spent on a model that carries one, such as `glm5.2`. Not retryable: the counter returns to zero when your billing period starts. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint or model. Image generation requires inference membership. | `tier_restricted` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.2`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | — |\n| `503` | Web search is temporarily unavailable; retry shortly. | `search_unavailable` |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | — |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly: they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either — it clears when your billing period starts.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, reasoning | Streaming, tool calling, reasoning, 1M-token context. 500M tokens/month per member |\n| `mimo-v2.5` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision, agents | Streaming, tool calling, vision, reasoning (opt-in) |\n| `glm5.2` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, 500K-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n\n`glm5.2` is served only to keys on the GLM 5.2 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints — we add capabilities without breaking existing fields.", + "description": "Open models on a shared EU inference cluster. Zero logs.\n\nThe NaN API is OpenAI-compatible: predictable, resource-oriented URLs, JSON request and response bodies, and standard HTTP verbs and status codes. Point any OpenAI SDK at our base URL and your existing code keeps working. Change the base URL and the API key, and that's it.\n\nOne schema across every model, so you only learn the API once. Change the `model` field to switch models; everything else stays the same.\n\n- Base URL: `https://api.nan.builders/v1`\n- OpenAPI spec: this document. Import it into Postman, Insomnia, or your own tooling.\n\nIf you use the [Helmcode](https://helmcode.com) enterprise service, the base URL is `https://api.helmcode.com/v1` instead. Every other endpoint is identical.\n\n## Authentication\n\nEvery request authenticates with an API key, sent as a Bearer token:\n\n```\nAuthorization: Bearer $NAN_API_KEY\n```\n\nYou must be a NaN community member. Generate your key from user settings, under \"API Keys\", on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. Keep it secret: never embed one in client-side code or commit it to source control. Requests must go over HTTPS; calls over plain HTTP fail.\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"qwen3.6\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. Only `/chat/completions` streams incrementally; `/responses` currently emits a single terminal event.\n\n## Rate limits\n\nLimits apply per API key (RPM and concurrency), not on total token volume:\n\n| Limit | Value |\n| --- | --- |\n| Requests per minute | 60 |\n| Concurrent requests | 5 |\n| Tokens per minute (`deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4`) | 1.5M |\n| Requests per minute (`rerank`) | 1,000 |\n\n`glm5.2` is not gated by a per-minute rate but by a rolling window plus an allowance per billing period: 400M tokens per rolling 4 hours and a 3,000M-token allowance that returns to zero when your billing period starts. The window is rolling, not a daily reset.\n\nWeb search runs on its own budget, separate from the model endpoints: 20 requests per minute, 3 concurrent, and 500 searches per day per key. Image endpoints have their own too: 20 requests per minute and 100 requests per month. Exceed any limit and you get a `429`.\n\n## Errors\n\nNaN uses conventional HTTP status codes: `2xx` on success, `4xx` for a problem with the request (a missing parameter, an invalid key, an unavailable model) and `5xx` for a server-side error. Every error returns a JSON body in the OpenAI shape:\n\n```json\n{\n \"error\": {\n \"message\": \"The model 'foo' does not exist.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"model\",\n \"code\": \"model_not_found\"\n }\n}\n```\n\n`message` is human-readable, `param` names the offending field when applicable, and `code` is a short machine-readable string you can branch on.\n\n| Status | Meaning | `code` |\n| --- | --- | --- |\n| `400` | Invalid or malformed parameter (`param` says which); or content blocked by the safety filter. | `invalid_request_error` · `content_policy_violation` |\n| `401` | Missing or invalid API key. | `invalid_api_key` |\n| `402` | The token allowance for the billing period is spent on a model that carries one, such as `glm5.2`. Not retryable: the counter returns to zero when your billing period starts. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint or model. Image generation requires inference membership. | `tier_restricted` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.2`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | (none) |\n| `503` | Web search is temporarily unavailable; retry shortly. | `search_unavailable` |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | (none) |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly: they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either: it clears when your billing period starts.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, reasoning | Streaming, tool calling, reasoning, 1M-token context. 500M tokens/month per member |\n| `mimo-v2.5` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision, agents | Streaming, tool calling, vision, reasoning (opt-in) |\n| `glm5.2` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, 500K-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n\n`glm5.2` is served only to keys on the GLM 5.2 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints, and we add capabilities without breaking existing fields.", "contact": { "name": "NaN", "url": "https://nan.builders" @@ -160,7 +160,7 @@ "properties": { "model": { "type": "string", - "description": "Model id. See [List models](#tag/Models) for what your key can use. Chat models are `deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4` and `glm5.2`.\n\n`glm5.2` requires a key on the GLM 5.2 premium tier — other keys get `403` `tier_restricted`. The rest are available to every inference member.", + "description": "Model id. See [List models](#tag/Models) for what your key can use. Chat models are `deepseek-v4-flash`, `mimo-v2.5`, `qwen3.6`, `gemma4` and `glm5.2`.\n\n`glm5.2` requires a key on the GLM 5.2 premium tier; other keys get `403` `tier_restricted`. The rest are available to every inference member.", "example": "qwen3.6" }, "messages": { @@ -340,7 +340,7 @@ }, "premium_tier": { "summary": "Premium tier model (glm5.2)", - "description": "`glm5.2` is served only to keys on the GLM 5.2 premium tier. Same request shape as any other model — only `model` changes. A key without the tier gets `403` `tier_restricted`.", + "description": "`glm5.2` is served only to keys on the GLM 5.2 premium tier. Same request shape as any other model, only `model` changes. A key without the tier gets `403` `tier_restricted`.", "value": { "model": "glm5.2", "messages": [ @@ -778,12 +778,12 @@ { "lang": "python", "label": "Python", - "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\n# rerank isn't part of the OpenAI client — call it directly:\nresp = client.post(\n \"/rerank\",\n cast_to=object,\n body={\n \"model\": \"rerank\",\n \"query\": \"What is the capital of France?\",\n \"documents\": [\"Paris is the capital of France.\", \"Berlin is the capital of Germany.\"],\n },\n)\nfor r in resp[\"results\"]:\n print(r[\"index\"], r[\"relevance_score\"])" + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\n# rerank isn't part of the OpenAI client, so call it directly:\nresp = client.post(\n \"/rerank\",\n cast_to=object,\n body={\n \"model\": \"rerank\",\n \"query\": \"What is the capital of France?\",\n \"documents\": [\"Paris is the capital of France.\", \"Berlin is the capital of Germany.\"],\n },\n)\nfor r in resp[\"results\"]:\n print(r[\"index\"], r[\"relevance_score\"])" }, { "lang": "javascript", "label": "Node.js", - "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\n// rerank isn't part of the OpenAI client — call it directly:\nconst res = await fetch(\"https://api.nan.builders/v1/rerank\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${process.env.NAN_API_KEY}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model: \"rerank\",\n query: \"What is the capital of France?\",\n documents: [\"Paris is the capital of France.\", \"Berlin is the capital of Germany.\"],\n }),\n});\nconst data = await res.json();\nconsole.log(data.results);" + "source": "import OpenAI from \"openai\";\n\nconst client = new OpenAI({\n apiKey: process.env.NAN_API_KEY,\n baseURL: \"https://api.nan.builders/v1\",\n});\n\n// rerank isn't part of the OpenAI client, so call it directly:\nconst res = await fetch(\"https://api.nan.builders/v1/rerank\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${process.env.NAN_API_KEY}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model: \"rerank\",\n query: \"What is the capital of France?\",\n documents: [\"Paris is the capital of France.\", \"Berlin is the capital of Germany.\"],\n }),\n});\nconst data = await res.json();\nconsole.log(data.results);" } ] } @@ -1404,7 +1404,7 @@ "Search" ], "summary": "Web search", - "description": "An authenticated web search tool for agents. Give it a query and it returns ranked results (title, URL, snippet, source), drawn from a hybrid of upstream search providers. Results come back through the NaN API — your key never talks to a third-party search provider directly, and you never hold a provider key.\n\nSet `fetch_content: true` to also extract the readable main text of the top results (adds latency). You supply only `query`; when `fetch_content` is `true` the server fetches page content **only** for the URLs the search returned, through a hardened anti-SSRF fetcher — you cannot pass an arbitrary URL to fetch.\n\nLimits are per API key and separate from the model endpoints, so searching does not consume your chat RPM budget or vice versa: 20 requests per minute, 3 concurrent, and 500 searches per day. Exceeding the per-minute or concurrency limit returns `429` `rate_limit_exceeded`; exhausting the daily quota returns `429` `insufficient_quota`. A `429` carries a `Retry-After` header. Repeated identical queries within ~15 minutes are served from a short-lived cache (`cached: true`); cached hits still count toward your rate and quota.\n\n## Use it as an agent tool\n\nDrop this OpenAI-style function schema into your model call's `tools` array. When the model emits a `web_search` tool call, invoke `POST /v1/search` with the arguments and feed the JSON response back as the tool result.\n\n```json\n{\n \"type\": \"function\",\n \"function\": {\n \"name\": \"web_search\",\n \"description\": \"Search the public web and return relevant results (title, URL, snippet, and optional page content). Use it when the answer may depend on current events, recent developments, prices, release/version numbers, or facts you are not confident are up to date.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": { \"type\": \"string\", \"description\": \"The web search query.\" },\n \"count\": { \"type\": \"integer\", \"description\": \"Number of results to return (1-20).\", \"minimum\": 1, \"maximum\": 20, \"default\": 5 },\n \"freshness\": { \"type\": \"string\", \"description\": \"Restrict results by recency: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or a 'YYYY-MM-DDtoYYYY-MM-DD' date range. Omit for no time filter.\" },\n \"fetch_content\": { \"type\": \"boolean\", \"description\": \"When true, also fetch and include the readable text of the top results (slower). Default false returns snippets only.\", \"default\": false }\n },\n \"required\": [\"query\"]\n }\n }\n}\n```", + "description": "An authenticated web search tool for agents. Give it a query and it returns ranked results (title, URL, snippet, source), drawn from a hybrid of upstream search providers. Results come back through the NaN API, so your key never talks to a third-party search provider directly, and you never hold a provider key.\n\nSet `fetch_content: true` to also extract the readable main text of the top results (adds latency). You supply only `query`; when `fetch_content` is `true` the server fetches page content **only** for the URLs the search returned, through a hardened anti-SSRF fetcher, so you cannot pass an arbitrary URL to fetch.\n\nLimits are per API key and separate from the model endpoints, so searching does not consume your chat RPM budget or vice versa: 20 requests per minute, 3 concurrent, and 500 searches per day. Exceeding the per-minute or concurrency limit returns `429` `rate_limit_exceeded`; exhausting the daily quota returns `429` `insufficient_quota`. A `429` carries a `Retry-After` header. Repeated identical queries within ~15 minutes are served from a short-lived cache (`cached: true`); cached hits still count toward your rate and quota.\n\n## Use it as an agent tool\n\nDrop this OpenAI-style function schema into your model call's `tools` array. When the model emits a `web_search` tool call, invoke `POST /v1/search` with the arguments and feed the JSON response back as the tool result.\n\n```json\n{\n \"type\": \"function\",\n \"function\": {\n \"name\": \"web_search\",\n \"description\": \"Search the public web and return relevant results (title, URL, snippet, and optional page content). Use it when the answer may depend on current events, recent developments, prices, release/version numbers, or facts you are not confident are up to date.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": { \"type\": \"string\", \"description\": \"The web search query.\" },\n \"count\": { \"type\": \"integer\", \"description\": \"Number of results to return (1-20).\", \"minimum\": 1, \"maximum\": 20, \"default\": 5 },\n \"freshness\": { \"type\": \"string\", \"description\": \"Restrict results by recency: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or a 'YYYY-MM-DDtoYYYY-MM-DD' date range. Omit for no time filter.\" },\n \"fetch_content\": { \"type\": \"boolean\", \"description\": \"When true, also fetch and include the readable text of the top results (slower). Default false returns snippets only.\", \"default\": false }\n },\n \"required\": [\"query\"]\n }\n }\n}\n```", "requestBody": { "required": true, "content": { @@ -1501,7 +1501,7 @@ { "lang": "python", "label": "Python", - "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\n# search isn't part of the OpenAI client — call it directly:\nresp = client.post(\n \"/search\",\n cast_to=object,\n body={\"query\": \"latest go release\", \"count\": 5, \"freshness\": \"pw\"},\n)\nfor r in resp[\"results\"]:\n print(r[\"title\"], r[\"url\"])" + "source": "from openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\n# search isn't part of the OpenAI client, so call it directly:\nresp = client.post(\n \"/search\",\n cast_to=object,\n body={\"query\": \"latest go release\", \"count\": 5, \"freshness\": \"pw\"},\n)\nfor r in resp[\"results\"]:\n print(r[\"title\"], r[\"url\"])" }, { "lang": "javascript", @@ -1518,7 +1518,7 @@ "MCP" ], "summary": "MCP server (JSON-RPC)", - "description": "Remote [Model Context Protocol](https://modelcontextprotocol.io) server, so our tools can be used inside any MCP-compatible agent or client with the same `sk-` key as the REST API.\n\nTransport is streamable HTTP and stateless; the protocol is JSON-RPC 2.0 with the methods `initialize`, `tools/list`, `tools/call` and `ping`. Today the server exposes a single tool, `web_search`, with the same arguments as [Web search](#tag/Search); it is a growing registry, so use `tools/list` to discover the current set.\n\nMCP calls share the **same** per-key rate limit, daily quota and concurrency as the equivalent REST endpoint — there is no separate budget. A `web_search` tool call over MCP counts exactly like a `POST /v1/search` request.\n\nNote this endpoint lives at the host root, `https://api.nan.builders/mcp`, not under `/v1`.", + "description": "Remote [Model Context Protocol](https://modelcontextprotocol.io) server, so our tools can be used inside any MCP-compatible agent or client with the same `sk-` key as the REST API.\n\nTransport is streamable HTTP and stateless; the protocol is JSON-RPC 2.0 with the methods `initialize`, `tools/list`, `tools/call` and `ping`. Today the server exposes a single tool, `web_search`, with the same arguments as [Web search](#tag/Search); it is a growing registry, so use `tools/list` to discover the current set.\n\nMCP calls share the **same** per-key rate limit, daily quota and concurrency as the equivalent REST endpoint, so there is no separate budget. A `web_search` tool call over MCP counts exactly like a `POST /v1/search` request.\n\nNote this endpoint lives at the host root, `https://api.nan.builders/mcp`, not under `/v1`.", "requestBody": { "required": true, "content": { diff --git a/src/lib/apiDoc.ts b/src/lib/apiDoc.ts index 845a55b..f50e93c 100644 --- a/src/lib/apiDoc.ts +++ b/src/lib/apiDoc.ts @@ -2,18 +2,18 @@ import spec from '../data/openapi.json'; import { openapiToText } from './openapiToText'; /** - * La referencia de API como entrada de docs, generada desde el spec. + * The API reference as a docs entry, generated from the spec. * - * Desde que /docs/api la sirve Scalar, `api` ya no es un fichero de la - * colección: sale de src/data/openapi.json. Pero los consumidores de - * /api/docs —el bot de Discord, hoy— no tienen por qué enterarse de eso, así - * que se sigue publicando con el mismo slug, el mismo orden y la misma forma - * que tenía cuando era `api.mdx`. El contrato del manifest no cambia; cambia - * de dónde sale el texto. + * Since Scalar took over /docs/api, `api` is no longer a file in the + * collection: it comes from src/data/openapi.json. Consumers of /api/docs + * (the Discord bot today) have no reason to learn about that, so it keeps + * being published under the same slug, the same order and the same shape it + * had as `api.mdx`. The manifest contract does not change; only where the + * text comes from does. * - * Los metadatos replican el frontmatter que tenía `api.mdx` (incluido - * `order: 2`) para que ni el orden del manifest ni el de la navegación se - * muevan con la migración. + * The metadata mirrors the frontmatter `api.mdx` used to carry (`order: 2` + * included) so that neither the manifest order nor the docs navigation moves + * with the migration. */ export const API_DOC_SLUG = 'api'; @@ -26,9 +26,9 @@ export const API_DOC_META = { let cached: string | null = null; /** - * El texto canónico de la referencia. Se memoiza porque el spec es estático - * dentro de un despliegue: recorrer 12 endpoints y 23 esquemas en cada - * petición del manifest sería trabajo repetido para un resultado idéntico. + * The canonical text of the reference. Memoised because the spec is static + * within a deployment: walking 12 endpoints and 23 schemas on every manifest + * request would be repeated work for an identical result. */ export function getApiDocText(): string { if (cached === null) cached = openapiToText(spec); diff --git a/src/lib/canonicalParity.test.ts b/src/lib/canonicalParity.test.ts index 97e5673..6ab59ea 100644 --- a/src/lib/canonicalParity.test.ts +++ b/src/lib/canonicalParity.test.ts @@ -72,11 +72,11 @@ const corpus: Array<{ label: string; body: string }> = [ ]; /** - * La referencia de API ya no pasa por mdxToText: se genera desde el spec. Sigue - * viajando por el mismo sitio (/api/docs/api.md) y la vuelve a canonicalizar el - * mismo bot, así que tiene que cumplir exactamente las mismas propiedades. Sin - * esto, el hash del manifest y el que calcula el bot no coincidirían nunca y - * reindexaría la referencia entera en cada versión. + * The API reference no longer goes through mdxToText: it is generated from the + * spec. It still travels the same way (/api/docs/api.md) and is re-canonicalised + * by the same bot, so it has to satisfy exactly the same properties. Without + * this, the manifest hash and the one the bot computes would never agree and it + * would re-index the whole reference on every version. */ describe('openapiToText output is a fixed point of the bot canonicaliser', () => { const out = getApiDocText(); diff --git a/src/lib/docsManifestRoute.test.ts b/src/lib/docsManifestRoute.test.ts index e0b5a8e..b313e2f 100644 --- a/src/lib/docsManifestRoute.test.ts +++ b/src/lib/docsManifestRoute.test.ts @@ -204,8 +204,8 @@ describe('GET /api/docs/manifest.json', () => { expect(typeof body.version).toBe('string'); expect(body.version.startsWith('sha256:')).toBe(true); expect(Array.isArray(body.entries)).toBe(true); - // Las dos de la colección más la referencia de API, que se genera desde el - // spec y no depende de ella. + // The two from the collection plus the API reference, generated from the + // spec and independent of it. expect(body.entries.length).toBe(3); for (const e of body.entries) { expect(e).toHaveProperty('slug'); @@ -217,11 +217,11 @@ describe('GET /api/docs/manifest.json', () => { }); /** - * El bot de Discord no falla cuando un slug desaparece del manifest: borra - * sus chunks en silencio (bot/knowledge.py::load_documentation_from_remote, - * `stale_sources`). Cuando /docs/api pasó a servirse con Scalar, `api` dejó - * de estar en la colección, así que sin esta entrada sintética el bot habría - * perdido la referencia de API sin que saltara ninguna alarma. + * The Discord bot does not fail when a slug disappears from the manifest: it + * silently drops its chunks (bot/knowledge.py::load_documentation_from_remote, + * `stale_sources`). When /docs/api moved to Scalar, `api` left the collection, + * so without this synthetic entry the bot would have lost the API reference + * with no alarm going off. */ it('publishes the API reference even though it is not in the collection', async () => { getCollectionMock.mockResolvedValue([entry('intro', '# Intro\n\nHola.\n')]); @@ -236,7 +236,7 @@ describe('GET /api/docs/manifest.json', () => { expect(api.title).toBe('API'); }); - /** Dos entradas con el mismo slug harían que el bot la indexara dos veces. */ + /** Two entries with the same slug would make the bot index it twice. */ it('keeps a single `api` entry if the collection ever gets one back', async () => { getCollectionMock.mockResolvedValue([ entry('intro', '# Intro\n\nHola.\n'), @@ -248,7 +248,7 @@ describe('GET /api/docs/manifest.json', () => { const apiEntries = body.entries.filter((e: { slug: string }) => e.slug === 'api'); expect(apiEntries.length).toBe(1); - // Gana el spec, no el fichero. + // The spec wins, not the file. expect(apiEntries[0].title).toBe('API'); }); }); diff --git a/src/lib/openapiSpec.test.ts b/src/lib/openapiSpec.test.ts index 527d4ed..13f63e1 100644 --- a/src/lib/openapiSpec.test.ts +++ b/src/lib/openapiSpec.test.ts @@ -2,22 +2,22 @@ import { describe, expect, it } from 'vitest'; import spec from '../data/openapi.json'; /** - * Tripwire sobre src/data/openapi.json, el spec que Scalar renderiza en - * /docs/api y del que sale el Markdown que consume el bot de Discord. + * Tripwire over src/data/openapi.json, the spec Scalar renders at /docs/api + * and the source of the Markdown the Discord bot consumes. * - * Hace falta porque el spec es un dato, no código: nada lo type-checkea y un - * error dentro se publica en silencio. Y este spec en concreto se derivó del de - * helmcode.com, cuyo catálogo y cuyo modelo de cobro NO son los de NaN, así que - * lo que se vigila sobre todo es que no vuelva a colarse nada de allí. + * It exists because the spec is data, not code: nothing type-checks it and a + * mistake inside ships silently. And this spec in particular was derived from + * helmcode.com's, whose catalogue and billing model are NOT NaN's, so what + * this mostly watches for is anything from there creeping back in. * - * La superficie de endpoints se comprobó contra el backend real sondeando cada - * ruta: las 12 de aquí responden 401 (existen, piden auth) y /v1/moderations, - * /v1/batches y /v1/files responden 404 (no están habilitadas en NaN). + * The endpoint surface was checked against the real backend by probing each + * route: the 12 listed here answer 401 (they exist and want auth) while + * /v1/moderations, /v1/batches and /v1/files answer 404 (not enabled on NaN). */ const raw = JSON.stringify(spec); -/** Las 12 rutas públicas verificadas contra api.nan.builders. */ +/** The 12 public routes verified against api.nan.builders. */ const PUBLIC_SURFACE: Array<[string, string]> = [ ['/models', 'get'], ['/chat/completions', 'post'], @@ -33,7 +33,7 @@ const PUBLIC_SURFACE: Array<[string, string]> = [ ['/mcp', 'post'], ]; -/** El catálogo real de NaN (src/data/modelos.json + la referencia de API). */ +/** NaN's real catalogue (src/data/modelos.json + the API reference). */ const NAN_MODELS = [ 'deepseek-v4-flash', 'mimo-v2.5', @@ -47,16 +47,16 @@ const NAN_MODELS = [ 'flux-2-klein', ]; -describe('openapi.json — estructura', () => { - it('declara OpenAPI 3.1', () => { +describe('openapi.json: structure', () => { + it('declares OpenAPI 3.1', () => { expect(spec.openapi).toMatch(/^3\.1/); }); - it('apunta al servidor de NaN', () => { + it("points at NaN's server", () => { expect(spec.servers?.[0]?.url).toBe('https://api.nan.builders/v1'); }); - it('todos los $ref resuelven', () => { + it('every $ref resolves', () => { const missing: string[] = []; const walk = (node: unknown) => { if (Array.isArray(node)) return node.forEach(walk); @@ -81,18 +81,18 @@ describe('openapi.json — estructura', () => { }); }); -describe('openapi.json — la superficie es la que el backend sirve de verdad', () => { +describe('openapi.json: the surface is what the backend actually serves', () => { for (const [path, method] of PUBLIC_SURFACE) { - it(`documenta ${method.toUpperCase()} ${path}`, () => { + it(`documents ${method.toUpperCase()} ${path}`, () => { const paths = spec.paths as Record>; expect(paths[path], path).toBeDefined(); expect(paths[path][method], `${method} ${path}`).toBeDefined(); }); } - it('no documenta endpoints que NaN no sirve', () => { + it('does not document endpoints NaN does not serve', () => { const documented = Object.keys(spec.paths as Record); - // Estos tres responden 404 en api.nan.builders. + // These three answer 404 on api.nan.builders. for (const absent of ['/moderations', '/batches', '/files']) { expect(documented).not.toContain(absent); } @@ -100,13 +100,15 @@ describe('openapi.json — la superficie es la que el backend sirve de verdad', }); }); -describe('openapi.json — no queda nada de helmcode.com', () => { - it('no nombra modelos que NaN no sirve', () => { - const foreign = raw.match(/\b(claude-[a-z0-9.-]+|gpt-[0-9][a-z0-9.-]*|gemini-[0-9][a-z0-9.-]*)\b/g); +describe('openapi.json: nothing left over from helmcode.com', () => { + it('names no model NaN does not serve', () => { + const foreign = raw.match( + /\b(claude-[a-z0-9.-]+|gpt-[0-9][a-z0-9.-]*|gemini-[0-9][a-z0-9.-]*)\b/g, + ); expect(foreign).toBeNull(); }); - it('no describe el cobro por crédito prepago ni la reventa', () => { + it('does not describe prepaid-credit billing or resale', () => { for (const term of [ 'prepaid', 'credit balance', @@ -120,31 +122,35 @@ describe('openapi.json — no queda nada de helmcode.com', () => { } }); - it('solo menciona Helmcode como la nota del servicio enterprise', () => { - // Una sola mención, y en info: la que dice que la enterprise usa otra base - // URL. Cualquier otra sería marca sin migrar. + it('mentions Helmcode only as the enterprise-service note', () => { + // A single mention, and inside info: the one saying enterprise uses a + // different base URL. Any other would be un-migrated branding. const outsideInfo = JSON.stringify({ ...spec, info: undefined }); expect(outsideInfo.toLowerCase()).not.toContain('helmcode'); }); }); -describe('openapi.json — el catálogo de modelos', () => { +describe('openapi.json: the model catalogue', () => { /** - * Los identificadores con pinta de modelo que aparecen en el spec tienen que - * estar todos en el catálogo. Es la red que evita publicar un modelo que no - * existe, que es justo lo que ya pasó una vez con glm5.2 en sentido inverso. + * Every model-looking identifier appearing in the spec must be in the + * catalogue. This is the net that stops us publishing a model that does not + * exist, which is exactly what happened once with glm5.2 the other way round. */ - it('no cita ningún identificador de modelo fuera del catálogo', () => { + it('cites no model identifier outside the catalogue', () => { const cited = new Set( (raw.match(/`([a-z0-9][a-z0-9.-]{2,})`/g) ?? []) .map((m) => m.slice(1, -1)) - .filter((token) => /^(deepseek|qwen|gemma|glm|mimo|kokoro|whisper|flux|rerank|claude|gpt|gemini|llama|mistral)/.test(token)), + .filter((token) => + /^(deepseek|qwen|gemma|glm|mimo|kokoro|whisper|flux|rerank|claude|gpt|gemini|llama|mistral)/.test( + token, + ), + ), ); const unknown = [...cited].filter((m) => !NAN_MODELS.includes(m)); expect(unknown).toEqual([]); }); - it('publica glm5.2 como modelo de chat del tier premium', () => { + it('publishes glm5.2 as a premium-tier chat model', () => { const chat = (spec.paths as any)['/chat/completions'].post.requestBody.content[ 'application/json' ].schema.properties.model.description as string; diff --git a/src/lib/openapiToText.ts b/src/lib/openapiToText.ts index ffbf20e..bc4f531 100644 --- a/src/lib/openapiToText.ts +++ b/src/lib/openapiToText.ts @@ -1,29 +1,29 @@ import { normalizeCanonicalText } from './mdxToText'; /** - * Convierte el spec OpenAPI en el Markdown que /api/docs sirve como si fuera - * una página más de la colección. + * Turns the OpenAPI spec into the Markdown /api/docs serves as if it were one + * more page of the collection. * - * Existe por el bot de Discord. Cuando la referencia de API dejó de ser - * `api.mdx`, el manifest se habría quedado sin la entrada `api`, y el bot no - * falla ante eso: borra en silencio los chunks de la fuente que desaparece - * (`stale_sources` en bot/knowledge.py::load_documentation_from_remote). Es - * decir, habríamos perdido justo el contenido que más se le pregunta sin que - * saltara ninguna alarma. + * It exists because of the Discord bot. When the API reference stopped being + * `api.mdx`, the manifest would have lost its `api` entry, and the bot does + * not fail on that: it silently drops the chunks of a source that disappears + * (`stale_sources` in bot/knowledge.py::load_documentation_from_remote). We + * would have lost the content it gets asked about the most without a single + * alarm going off. * - * Servir el JSON crudo tampoco vale: el bot trocea por PÁRRAFOS en bloques de - * ~2000 caracteres (bot/knowledge.py::chunk_text) y embebe cada trozo. Un - * openapi.json partido cada 2000 caracteres produce fragmentos cortados a - * mitad de llave y embeddings sin sentido. Lo que el bot necesita es prosa en - * párrafos, y de dónde salga esa prosa le da igual. + * Serving the raw JSON is no good either: the bot splits on PARAGRAPHS into + * ~2000-character blocks (bot/knowledge.py::chunk_text) and embeds each one. + * An openapi.json cut every 2000 characters yields fragments severed + * mid-brace and meaningless embeddings. What the bot needs is prose in + * paragraphs, and where that prose comes from is none of its concern. * - * La salida pasa por normalizeCanonicalText para ser un punto fijo del - * canonicalizador del bot, igual que la de mdxToText: si no lo fuera, su hash - * nunca coincidiría con el nuestro y reindexaría la referencia entera en cada - * versión del manifest. + * The output goes through normalizeCanonicalText so it is a fixed point of + * the bot's canonicaliser, exactly like mdxToText's: otherwise its hash would + * never agree with ours and it would re-index the whole reference on every + * manifest version. */ -/** Sub-conjunto del spec que se lee aquí. El resto se ignora sin molestar. */ +/** The subset of the spec read here. Everything else is ignored quietly. */ interface Spec { info?: { title?: string; description?: string; version?: string }; servers?: Array<{ url?: string; description?: string }>; @@ -43,8 +43,8 @@ interface Operation { deprecated?: boolean; parameters?: Parameter[]; requestBody?: { required?: boolean; content?: Record }; - // Una respuesta es un Response Object o un Reference Object a - // #/components/responses: los códigos compartidos (401, 429…) van por $ref. + // A response is either a Response Object or a Reference Object into + // #/components/responses: shared codes (401, 429...) travel as a $ref. responses?: Record; 'x-codeSamples'?: Array<{ lang?: string; label?: string; source?: string }>; } @@ -67,18 +67,18 @@ interface MediaType { } interface SchemaLike { - // En OpenAPI 3.1 `type` puede ser una lista: `["string", "null"]` es como se - // declara un campo anulable (finish_reason, entre otros). + // In OpenAPI 3.1 `type` may be a list: `["string", "null"]` is how a + // nullable field is declared (finish_reason, among others). type?: string | string[]; format?: string; enum?: unknown[]; default?: unknown; description?: string; required?: string[]; - // El valor va como opcional porque un `oneOf` de objetos con claves distintas - // hace que TypeScript infiera `clave?: undefined` en las ramas que no la - // tienen (ContentPart: text | image_url). typeName y describeField ya tratan - // el undefined. + // The value is optional because a `oneOf` of objects with different keys + // makes TypeScript infer `key?: undefined` on the branches that lack it + // (ContentPart: text | image_url). typeName and describeField already + // handle the undefined. properties?: Record; items?: SchemaLike; $ref?: string; @@ -95,10 +95,10 @@ function refName(ref: string): string { } /** - * Nombre legible del tipo de un campo, para la columna "Type" de las tablas. - * No resuelve el `$ref`: lo nombra y enlaza mentalmente al esquema, que se - * documenta aparte. Resolverlo aquí expandiría el mismo objeto una vez por - * endpoint y multiplicaría el tamaño del texto que se embebe. + * Readable type name for a field, for the "Type" column of the tables. + * It does not resolve the `$ref`: it names it and leaves the reader to the + * schema, which is documented separately. Resolving it here would expand the + * same object once per endpoint and multiply the size of the embedded text. */ function typeName(schema: SchemaLike | undefined): string { if (!schema) return 'any'; @@ -114,15 +114,15 @@ function typeName(schema: SchemaLike | undefined): string { } /** - * Aplana el texto para una celda de tabla: los saltos de línea romperían la - * fila en Markdown y la barra vertical abriría una columna nueva. + * Flattens text for a table cell: newlines would break the Markdown row and a + * pipe would open a new column. */ function cell(text: string | undefined): string { if (!text) return ''; return text.replace(/\r?\n+/g, ' ').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim(); } -/** Describe un campo: descripción + enum + default, en una sola celda. */ +/** Describes a field: description + enum + default, in a single cell. */ function describeField(schema: SchemaLike | undefined, description?: string): string { const parts: string[] = []; const desc = description ?? schema?.description; @@ -134,7 +134,7 @@ function describeField(schema: SchemaLike | undefined, description?: string): st return parts.join(' '); } -/** Tabla de propiedades de un objeto. Devuelve '' si no hay ninguna. */ +/** Property table for an object. Returns '' when there are none. */ function propertyTable(schema: SchemaLike | undefined): string { if (!schema?.properties) return ''; const required = new Set(schema.required ?? []); @@ -147,16 +147,16 @@ function propertyTable(schema: SchemaLike | undefined): string { } /** - * Baja de nivel los encabezados que traiga una descripción del spec. + * Demotes any headings a spec description carries. * - * Están escritos para Scalar, que los pinta dentro del panel del endpoint, así - * que empiezan en `##`. Volcados tal cual en un documento plano quedarían por - * encima del `###` del propio endpoint al que pertenecen: el `## Use it as an - * agent tool` de /search se leía como una sección hermana de "Search" en vez de - * como parte suya. Se hunden hasta quedar por debajo, con tope en `######`. + * They are written for Scalar, which paints them inside the endpoint panel, so + * they start at `##`. Dumped as-is into a flat document they would outrank the + * `###` of the very endpoint they belong to: the `## Use it as an agent tool` + * of /search read as a sibling section of "Search" instead of part of it. They + * sink until they sit below, capped at `######`. * - * Solo cuentan los encabezados a principio de línea y fuera de un bloque de - * código: dentro de un ``` un `#` suele ser un comentario de shell. + * Only headings at the start of a line and outside a code fence count: inside + * a ``` a `#` is usually a shell comment. */ function demoteHeadings(markdown: string, by: number): string { let inFence = false; @@ -177,12 +177,12 @@ function demoteHeadings(markdown: string, by: number): string { } /** - * Descripción de una respuesta, resolviendo el $ref si lo lleva. + * A response's description, resolving the $ref when it carries one. * - * Los códigos compartidos (401, 403, 429, 402) están en components.responses y - * cada endpoint los referencia. Sin resolverlos, la tabla de respuestas salía - * con la celda vacía —`| 401 | |`— y quien leyera el texto (o el bot) no se - * enteraba de qué significa cada código en ese endpoint. + * The shared codes (401, 403, 429, 402) live in components.responses and each + * endpoint references them. Without resolving them the response table came out + * with an empty cell (`| 401 | |`) and whoever read the text (or the bot) had + * no idea what each code means on that endpoint. */ function responseDescription(spec: Spec, res: ResponseOrRef | undefined): string { if (!res) return ''; @@ -200,10 +200,10 @@ function renderOperation(spec: Spec, path: string, method: string, op: Operation const out: string[] = []; const title = op.summary || op.operationId || `${method.toUpperCase()} ${path}`; - out.push(`### ${method.toUpperCase()} ${path} — ${title}`); + out.push(`### ${method.toUpperCase()} ${path}: ${title}`); if (op.deprecated) out.push('**Deprecated.**'); - // La operación es un `###`, así que sus subsecciones bajan a `#####` para no - // competir con los `####` que genera esta misma función (Parameters, etc.). + // The operation is a `###`, so its subsections drop to `#####` so they do + // not compete with the `####` this same function emits (Parameters, etc.). if (op.description) out.push(demoteHeadings(op.description.trim(), 3)); const params = op.parameters ?? []; @@ -246,14 +246,14 @@ function renderOperation(spec: Spec, path: string, method: string, op: Operation for (const sample of op['x-codeSamples'] ?? []) { if (!sample.source) continue; - out.push(`#### Example — ${sample.label || sample.lang || 'request'}`); + out.push(`#### Example: ${sample.label || sample.lang || 'request'}`); out.push(`\`\`\`${sample.lang || ''}\n${sample.source}\n\`\`\``); } return out.join('\n\n'); } -/** Documenta los esquemas reutilizables, que los endpoints solo nombran. */ +/** Documents the reusable schemas, which the endpoints only name. */ function renderSchemas(spec: Spec): string { const schemas = spec.components?.schemas ?? {}; const names = Object.keys(schemas).sort(); @@ -278,9 +278,9 @@ function renderSchemas(spec: Spec): string { } /** - * El spec entero como Markdown. Se agrupa por tag, en el orden en que el spec - * los declara, que es el orden en el que Scalar los pinta: el texto que lee el - * bot y la página que lee una persona recorren la API en el mismo orden. + * The whole spec as Markdown. Grouped by tag, in the order the spec declares + * them, which is the order Scalar paints them in: the text the bot reads and + * the page a person reads walk the API in the same order. */ export function openapiToText(spec: Spec): string { const out: string[] = []; @@ -292,7 +292,7 @@ export function openapiToText(spec: Spec): string { if (server) out.push(`Base URL: \`${server}\``); if (info.description) out.push(info.description.trim()); - // Operaciones indexadas por tag, conservando el orden de aparición. + // Operations indexed by tag, preserving order of appearance. const byTag = new Map(); const tagOrder: string[] = (spec.tags ?? []).map((t) => t.name ?? '').filter(Boolean); diff --git a/src/pages/api/docs/[slug].md.ts b/src/pages/api/docs/[slug].md.ts index 934da30..3ca67b3 100644 --- a/src/pages/api/docs/[slug].md.ts +++ b/src/pages/api/docs/[slug].md.ts @@ -17,8 +17,8 @@ export const GET: APIRoute = async ({ params, request }) => { } try { - // `api` no está en la colección desde que Scalar sirve la referencia: su - // cuerpo se genera desde el spec. Ver src/lib/apiDoc.ts. + // `api` is not in the collection now that Scalar serves the reference: its + // body is generated from the spec. See src/lib/apiDoc.ts. let body: string; if (slug === API_DOC_SLUG) { body = getApiDocText(); diff --git a/src/pages/api/docs/manifest.json.ts b/src/pages/api/docs/manifest.json.ts index c7d31f5..ea71fc0 100644 --- a/src/pages/api/docs/manifest.json.ts +++ b/src/pages/api/docs/manifest.json.ts @@ -23,9 +23,9 @@ export const GET: APIRoute = async ({ request }) => { }); const rateLimits = getRateLimitsConfig(env); - // Si alguien vuelve a añadir un `api.md(x)` a la colección, el spec sigue - // mandando: dos entradas con el mismo slug le harían indexar la referencia - // dos veces, y con contenidos distintos. + // If anyone adds an `api.md(x)` back to the collection, the spec still + // wins: two entries with the same slug would make consumers index the + // reference twice, with different contents. const collectionEntries = await Promise.all( entries .filter((entry) => entry.id !== API_DOC_SLUG) @@ -43,9 +43,9 @@ export const GET: APIRoute = async ({ request }) => { }), ); - // La referencia de API ya no es un fichero de la colección: la sirve Scalar - // desde el spec. Se publica igualmente como entrada, porque para quien - // consume el manifest sigue siendo una página de docs más. Ver src/lib/apiDoc.ts. + // The API reference is no longer a file in the collection: Scalar serves it + // from the spec. It is published as an entry all the same, because to a + // manifest consumer it is still one more docs page. See src/lib/apiDoc.ts. const apiText = getApiDocText(); const apiEntry = { slug: API_DOC_SLUG, diff --git a/src/pages/docs/api.astro b/src/pages/docs/api.astro index a33b09f..547fc9c 100644 --- a/src/pages/docs/api.astro +++ b/src/pages/docs/api.astro @@ -1,6 +1,6 @@ --- /* - * /docs/api — la referencia interactiva. + * /docs/api: la referencia interactiva. * * Ruta estática, así que gana al catch-all `[...slug].astro` que sirve el resto * de la colección de docs. `api.mdx` ya no existe: el contenido sale del spec. diff --git a/src/pages/es/docs/api.astro b/src/pages/es/docs/api.astro index 2ef1785..c53745f 100644 --- a/src/pages/es/docs/api.astro +++ b/src/pages/es/docs/api.astro @@ -1,6 +1,6 @@ --- /* - * /es/docs/api — misma referencia con el chrome y las etiquetas de Scalar en + * /es/docs/api: misma referencia con el chrome y las etiquetas de Scalar en * español. El contenido del spec se queda en inglés, por convención de las * referencias de API. * diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts index 2cc20c4..b4a4e9f 100644 --- a/src/pages/sitemap.xml.ts +++ b/src/pages/sitemap.xml.ts @@ -79,7 +79,7 @@ export const GET: APIRoute = async () => { /* * La referencia de API va aparte porque ya no sale de la colección: la sirve * Scalar desde el spec. Y sí lleva alternates, porque es la única página de - * /docs que existe en los dos idiomas — el spec se queda en inglés, pero el + * /docs que existe en los dos idiomas: el spec se queda en inglés, pero el * chrome y las etiquetas de Scalar están traducidos y /es/docs/api es una URL * real que queremos indexada como tal. */ diff --git a/src/styles/scalar-theme.css b/src/styles/scalar-theme.css index 2246173..2203680 100644 --- a/src/styles/scalar-theme.css +++ b/src/styles/scalar-theme.css @@ -1,5 +1,5 @@ /* ============================================ - Scalar API Reference — tema de NaN + Scalar API Reference: tema de NaN ============================================ Scalar se tematiza entero con variables CSS (--scalar-*). Aquí se mapean a @@ -27,7 +27,7 @@ /* Scalar aplica el tema bajo .dark-mode. El componente lo fuerza siempre. */ .dark-mode { --scalar-background-1: var(--color-bg); /* base */ - --scalar-background-2: var(--color-surface); /* elevado — cards, bloques de código */ + --scalar-background-2: var(--color-surface); /* elevado: cards, bloques de código */ --scalar-background-3: #1B1B1F; /* hovers y anidados: un escalón sobre --color-surface */ --scalar-background-accent: var(--color-violet-dim); @@ -44,7 +44,7 @@ --scalar-border-color: var(--color-line); /* Paleta semántica: pastillas de método HTTP, badges de tipo y resaltado de - sintaxis. Es la única parte que NO puede colapsar al monoacento — un POST + sintaxis. Es la única parte que NO puede colapsar al monoacento: un POST y un DELETE tienen que distinguirse entre sí. */ --scalar-color-green: #3FB950; --scalar-color-red: #F85149; diff --git a/src/tests/lib/docsGlm52.test.ts b/src/tests/lib/docsGlm52.test.ts index 5b456b1..87fcb3b 100644 --- a/src/tests/lib/docsGlm52.test.ts +++ b/src/tests/lib/docsGlm52.test.ts @@ -14,10 +14,10 @@ import { mdxToText } from '../../lib/mdxToText'; * asserts here are on the text /api/docs actually serves (same extractor the * Discord bot consumes), so the page and the API are covered at once. * - * Desde la migración a Scalar, la mitad de `api` ya no sale de `api.mdx` sino - * del spec (src/data/openapi.json) a través de openapiToText. La redacción - * cambió con ella; lo que se comprueba sigue siendo lo mismo: que glm5.2 se - * publique como llamable, con sus límites reales y sin decir de dónde sale. + * Since the Scalar migration, the `api` half no longer comes from `api.mdx` but + * from the spec (src/data/openapi.json) via openapiToText. The wording changed + * with it; what is asserted is still the same: that glm5.2 is published as + * callable, with its real limits and without saying where it is sourced from. */ const here = dirname(fileURLToPath(import.meta.url)); @@ -128,9 +128,9 @@ describe('docs/api — glm5.2 is callable', () => { }); /** - * El 402 dejó de ser el crédito prepago de helmcode. Si al reconstruir el - * spec se recupera aquel texto, volvería a publicarse un modelo de cobro que - * NaN no tiene. + * The 402 stopped being helmcode's prepaid credit. If rebuilding the spec + * brings that text back, we would again publish a billing model NaN does not + * have. */ test('does not describe a prepaid-credit billing model', () => { expect(api).not.toMatch(/prepaid|credit balance|credits_exhausted|top ?up/i); diff --git a/src/tests/lib/sitemap.test.ts b/src/tests/lib/sitemap.test.ts index e19802c..5045248 100644 --- a/src/tests/lib/sitemap.test.ts +++ b/src/tests/lib/sitemap.test.ts @@ -130,7 +130,7 @@ describe('sitemap.xml', () => { * /docs/api dejó de salir de la colección: la sirve Scalar desde el spec. Es * además la única página de /docs que existe en español, así que es la única * de la sección que lleva alternates. Si algún día se vuelve a colar en la - * colección, saldría dos veces — lo cubre el test de URLs repetidas. + * colección, saldría dos veces, lo cubre el test de URLs repetidas. */ test('la referencia de API entra aunque no esté en la colección, y con sus dos idiomas', async () => { const { xml } = await sitemap([{ id: 'intro' }]); From 2ee22e03a5b9f09ce550dfa2e0967673376d8259 Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Wed, 12 Aug 2026 12:12:28 +0200 Subject: [PATCH 06/15] feat(docs): the brand wordmark in the documentation headers Both headers (/docs and /docs/api) rebuilt the brand by hand: the nan-logo.png icon (a 600x600 PNG scaled to 26px) plus a NaN of text. They now use the real wordmark, nan-wordmark-white.svg. It goes in as a CSS MASK rather than an , the way Nav.astro and Footer.astro do on the site: the SVG supplies only the silhouette and `background` supplies the colour, so it can turn violet on hover with a transition. With an the file would have to be kept in two colours. nan-logo.png is left unused. --- src/components/docs/ApiReference.astro | 35 ++++++++++++++++++-------- src/layouts/Docs.astro | 16 +++++++----- src/styles/global.css | 22 ++++++++++++++++ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/components/docs/ApiReference.astro b/src/components/docs/ApiReference.astro index fc8f947..128170f 100644 --- a/src/components/docs/ApiReference.astro +++ b/src/components/docs/ApiReference.astro @@ -43,6 +43,7 @@ const t = { platform: 'Platform ↗', site: 'nan.builders ↗', title: 'API Reference · NaN Docs', + brandHome: 'NaN, back to the docs', description: 'Interactive reference for the NaN API: OpenAI-compatible endpoints for chat, embeddings, rerank, audio, images and web search.', }, @@ -53,6 +54,7 @@ const t = { platform: 'Plataforma ↗', site: 'nan.builders ↗', title: 'Referencia API · NaN Docs', + brandHome: 'NaN, volver a los docs', description: 'Referencia interactiva de la API de NaN: endpoints compatibles con OpenAI para chat, embeddings, rerank, audio, imágenes y búsqueda web.', }, @@ -130,9 +132,8 @@ const CSP = [
- - NaN - NaN + + {t.docs}