From e1491d7a0fe698d0096419cfd9dea6fbc04e2220 Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Wed, 12 Aug 2026 13:13:37 +0200 Subject: [PATCH 1/4] feat(docs): Spanish routes for the guides, and the header language switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switcher only appeared on /docs/api because it was the only page with a Spanish version: /es/docs and /es/docs/models answered 404. helmcode can show it everywhere because it keeps every guide in both languages. Now so can we. The Spanish guides ship as COPIES of the English ones, marked `translated: false`, and the page says so instead of passing English off as a translation. Flipping that flag as each guide is really translated is the whole remaining job, and it is content work, not code. Where the files live is not cosmetic. The obvious layout, `docs/en/…` and `docs/es/…`, would turn every entry id into `en/intro`, and SAFE_SLUG in src/lib/docsApi.ts rejects slashes: the manifest route throws on the first one, so /api/docs/manifest.json would answer 500 and the Discord bot would lose its entire knowledge base. English stays exactly where it is and Spanish goes in its own collection, so the published slugs never move. The manifest itself is untouched and keeps serving English only. Whether the bot should index Spanish too is a separate decision with its own slug design. Also in here: - The wordmark in the header now goes to nan.builders instead of the docs index. It is the brand mark, and "Guides" right beside it already covers going to the docs index. - The breadcrumb is built from the locale-stripped path: counting `/es` as a segment made every Spanish page show an extra crumb for the docs index. - Chrome strings, TOC label, search placeholder and prev/next are translated. - Every guide is now listed in the sitemap in both languages with alternates. Verified in a browser across both languages: the switcher round-trips, the notice shows only where the flag says so, breadcrumbs read correctly and the layout holds. Tests verified by mutation: reverting the breadcrumb fix, removing a Spanish guide or letting the two collections disagree on order each make the suite fail. --- src/components/docs/DocsTopBar.astro | 11 +- src/content.config.ts | 59 +- src/content/docs-es/agents.md | 117 ++++ src/content/docs-es/apps.md | 74 +++ src/content/docs-es/examples.md | 644 ++++++++++++++++++++ src/content/docs-es/getting-started.md | 40 ++ src/content/docs-es/intro.md | 28 + src/content/docs-es/models.mdx | 258 ++++++++ src/layouts/Docs.astro | 92 ++- src/pages/es/docs/[...slug].astro | 30 + src/pages/sitemap.xml.ts | 16 +- src/styles/docs-shell.css | 14 + src/tests/layouts/DocsShell.test.ts | 11 +- src/tests/layouts/docsCopyIsEnglish.test.ts | 7 +- src/tests/layouts/docsI18n.test.ts | 98 +++ src/tests/lib/sitemap.test.ts | 17 +- 16 files changed, 1464 insertions(+), 52 deletions(-) create mode 100644 src/content/docs-es/agents.md create mode 100644 src/content/docs-es/apps.md create mode 100644 src/content/docs-es/examples.md create mode 100644 src/content/docs-es/getting-started.md create mode 100644 src/content/docs-es/intro.md create mode 100644 src/content/docs-es/models.mdx create mode 100644 src/pages/es/docs/[...slug].astro create mode 100644 src/tests/layouts/docsI18n.test.ts diff --git a/src/components/docs/DocsTopBar.astro b/src/components/docs/DocsTopBar.astro index 4368237..032c3e6 100644 --- a/src/components/docs/DocsTopBar.astro +++ b/src/components/docs/DocsTopBar.astro @@ -32,7 +32,7 @@ const t = { reference: 'API Reference', platform: 'Platform ↗', site: 'nan.builders ↗', - home: 'NaN, back to the docs', + home: 'NaN, go to nan.builders', menu: 'Toggle menu', language: 'Language', }, @@ -42,7 +42,7 @@ const t = { reference: 'Referencia API', platform: 'Plataforma ↗', site: 'nan.builders ↗', - home: 'NaN, volver a los docs', + home: 'NaN, ir a nan.builders', menu: 'Abrir o cerrar el menú', language: 'Idioma', }, @@ -57,7 +57,12 @@ const t = { - {t.docs} diff --git a/src/content.config.ts b/src/content.config.ts index 4eef4c1..4283529 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -2,24 +2,49 @@ import { defineCollection } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; +const docsSchema = z.object({ + title: z.string(), + description: z.string(), + order: z.number().int().min(0), + /* + * The heading the page appears under in the docs navigation. + * + * helmcode's nav writes the groups by hand in the layout; here they live in + * the data so adding a guide stays a matter of creating a file rather than + * also editing the layout, which is how these things drift apart. The order + * between groups comes from the lowest `order` in each, so there is no + * second list to maintain either. + */ + group: z.string().default('Guides'), + /* + * Whether the Spanish copy has actually been translated yet. + * + * The Spanish guides ship as copies of the English ones so /es/docs does not + * 404, and the page says so instead of passing English off as Spanish. Set + * it to true as each guide is translated. + */ + translated: z.boolean().default(true), + locale: z.string().default('es'), +}); + const docs = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }), - schema: z.object({ - title: z.string(), - description: z.string(), - order: z.number().int().min(0), - /* - * The heading the page appears under in the docs navigation. - * - * helmcode's nav writes the groups by hand in the layout; here they live in - * the data so adding a guide stays a matter of creating a file rather than - * also editing the layout, which is how these things drift apart. The order - * between groups comes from the lowest `order` in each, so there is no - * second list to maintain either. - */ - group: z.string().default('Guides'), - locale: z.string().default('es'), - }), + schema: docsSchema, +}); + +/* + * The Spanish guides live in their own directory rather than under a locale + * subfolder of `docs`. + * + * A `docs/en/…` + `docs/es/…` layout would turn every entry id into `en/intro` + * and the like, and SAFE_SLUG in src/lib/docsApi.ts rejects slashes: the + * manifest route throws on the first one, so /api/docs/manifest.json would + * answer 500 and the Discord bot would lose everything. Keeping English where + * it is leaves those slugs untouched. + */ +const docsEs = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs-es' }), + schema: docsSchema, }); -export const collections = { docs }; +export const collections = { docs, docsEs }; diff --git a/src/content/docs-es/agents.md b/src/content/docs-es/agents.md new file mode 100644 index 0000000..3a9fb9c --- /dev/null +++ b/src/content/docs-es/agents.md @@ -0,0 +1,117 @@ +--- +title: Agents +description: "Deploy AI agents in an isolated microVM with QEMU: Hermes, web terminal, file uploads, and observability." +order: 5 +group: Guides +translated: false +--- + +# Agents. + +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#tag/mcp). + +## Architecture + +Each agent runs inside its own QEMU microVM. Instead of sharing the host kernel (like a regular container), it starts with its own Linux kernel. The VM mounts a 20 GiB ext4 disk on a block-mode persistent volume. Everything you do inside — `apt install`, `pip install`, edits to `/etc`, files you upload — lives on that disk and survives restarts. + +Shutdown is *graceful*: when you restart or delete the agent, the system forces a `sync` and waits for the ext4 journal to finish flushing before killing the VM. No corruption. + +## Hermes + +Hermes is a conversational AI agent that connects to Telegram. You can chat with it, ask it to manage notes, run commands in its environment, generate websites, and much more. + +### 1. Create a Telegram bot + +You need a Telegram bot. Open Telegram, search for [@BotFather](https://core.telegram.org/bots/tutorial#obtain-your-bot-token) and follow the instructions to create a new bot. Copy the token it gives you. + +### 2. Create the agent + +Go to [cloud.nan.builders/agents/new](https://cloud.nan.builders/agents/new) and fill in: name, type (Hermes), the Telegram token, model, and optionally a *soul* (system prompt) that defines your agent's personality. + +![Agent creation form](/docs/agents/create-agent-form.png) + +### 3. Wait for it to be Running + +After creating the agent, wait ~30 seconds for the microVM to start, format the disk for the first time (`mkfs.ext4`), and seed the filesystem. The status changes to `Running` and Hermes to `Ready`. + +### 4. Chat with your agent + +Find your bot on Telegram and send it a message. Hermes will respond using the model you configured. + +![Hermes conversation on Telegram](/docs/agents/telegram-hermes-chat.jpg) + +> **Your agent is ready.** +> With these 4 steps you already have Hermes running. What follows below are additional agent panel features: web terminal, file uploads, observability, HTTP exposure, Hermes UI, and environment variable management. + +## Console — web terminal + +The **Console** tab opens an interactive terminal (`bash --login`) inside your microVM, without needing to configure SSH. The stream runs over WebSocket with xterm.js: auto-resize when you adjust the panel, status pill in the top right, and a reconnect button if the session drops. + +Typical use cases: + +- Install packages: `apt update && apt install -y nginx` +- Inspect the agent's internal logs +- Move files you've uploaded to their final location +- Use `htop`, `df -h`, `journalctl`, etc. + +> **Operational limits** +> 1 simultaneous session per agent · 10 min idle timeout · 30 min max duration per session. + +## Files — file uploads + +The **Files** tab allows uploading files to the microVM with drag-and-drop or file picker. Multi-file, sequential queue, live progress bar with MiB/s. Files land in `/persist/uploads/` and from there you can move them with the Console. + +- Max size: **200 MiB** per file. +- Transport: WebSocket with 256 KiB chunks and end-to-end backpressure. +- Filename sanitized server-side (no path traversal). +- Live listing of uploaded files (refreshes every 5s). + +## Observability + +The **Observability** tab groups three sub-tabs: + +- **Logs** — live stream of the agent's stdout/stderr via WebSocket. Buffer of the last 500 lines on the client. +- **Events** — Kubernetes Pod events (BackOff, Scheduled, Pulled, Killing...) with type, reason, message, age, and count. Auto-refresh every 15s. +- **Metrics** — actual CPU, RAM, and disk usage against configured limits. CPU/RAM via Prometheus (kubelet-cadvisor), disk via `df` inside the microVM (the filesystem is block-mode, kubelet can't see it). Refreshes every 10s. + +## Web — public exposure + +The **Web** tab has two sub-tabs for exposing HTTP services from the agent: + +### HTTP + +Any service your agent serves over HTTP (nginx, an API, a static site) you can expose publicly. For example, ask Hermes to install nginx with a custom HTML page: + +![Asking Hermes to install nginx with a custom HTML page](/docs/agents/telegram-nginx-setup.jpg) + +In the **Web → HTTP** tab, click **Enable HTTP**. By default port `80` is exposed; if your service listens on another port, specify it in **Container Port**. The platform generates a public URL at `*.apps.nan.builders`. + +![Website generated by Hermes visible from the public URL](/docs/agents/http-result.png) + +### Hermes UI + +Hermes includes a lightweight web UI ([nesquena/hermes-webui](https://github.com/nesquena/hermes-webui)) that always runs inside the agent. From **Web → Hermes UI** you can enable external access: the platform generates a URL like `webui--.apps.nan.builders` protected by a per-agent password shown in the panel. + +## Environment variables + +The **Env** tab lets you add, edit, and delete agent environment variables without touching the Deployment. Useful for injecting third-party API keys, configuring Hermes behavior, etc. + +Two variables are **protected** (edit-only, no delete): `OPENAI_API_KEY` (your cluster key, managed by the platform) and `TELEGRAM_BOT_TOKEN`. The rest are free to create, edit, or delete. + +## Resources and limits + +Each microVM is provisioned with: + +| Resource | Request | Limit | +|---|---|---| +| CPU | 200m | 1 vCPU | +| RAM | 512 Mi | 2 GiB | +| Disk | — | 20 GiB (block-mode PVC) | + +CPU and RAM are the microVM's maximum limits; actual usage is usually well below. Disk is persistent — everything you install or modify (packages, files, configurations) is preserved across restarts. If the disk fills up (90%+), free it from the Console (`du -sh /persist/*`). + +> **Current limit** +> Currently each member can deploy **1 microVM agent**. This limit will be expanded in future versions. diff --git a/src/content/docs-es/apps.md b/src/content/docs-es/apps.md new file mode 100644 index 0000000..b2649e6 --- /dev/null +++ b/src/content/docs-es/apps.md @@ -0,0 +1,74 @@ +--- +title: Apps +description: Deploy your apps from GitHub to NaN Cloud in minutes. +order: 6 +group: Guides +translated: false +--- + +# Apps. + +NaN Cloud lets you **deploy your own apps from a GitHub repository**: we build your image, publish it in your isolated environment, and serve it behind a public domain with HTTPS. All in one click. + +> **Before you start** +> Apps live inside a **Space**: your own environment with its own resource quota. If you have an active inference subscription, you receive **one free Basic Space** included in your membership. If not, you can purchase one from [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). + +## Available tiers + +Each Space belongs to a tier. The tier defines the total CPU, RAM, and storage quota shared across all apps you deploy within it. You can **upgrade or downgrade** at any time from the Space dashboard (downgrades are only allowed if your current usage fits within the new tier). + +| Tier | CPU | RAM | Disk | Pods | Price | +|---|---|---|---|---|---| +| Basic | 2 vCPU | 4 GiB | 20 GiB | 5 | Free with inference · $6 / €6 per month | +| Medium | 4 vCPU | 8 GiB | 40 GiB | 10 | $12 / €12 per month | +| Large | 4 vCPU | 16 GiB | 80 GiB | 20 | $24 / €24 per month | + +CPU and RAM are the **Space aggregate limits** (sum of all your apps). By default, each app you create starts with a comfortable limit of `500m` CPU and `500 MiB` RAM, enough for a typical API or worker; you can increase the per-app limit from the *Advanced options* section of the form up to consuming the full tier. Disk is shared via PVCs (5/10/20 per tier) and is only used by apps you mark as *persistent*. + +## 1. Create a Space + +Go to [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). If you're an inference member, you'll see a panel offering you a free Basic Space: choose a *slug* (1–20 characters, lowercase, no spaces) and click **Claim free Basic**. The slug will be used to build the public domains of your apps, so choose it wisely. + +![Claim a free Basic Space](/docs/apps/01-claim-free-space.png) + +The Space activates instantly. + +## 2. Create an App within the Space + +Open your newly created Space. You'll see the resource usage summary, the **Change plan** button if you want to upgrade at any point, and the **Apps in this Space** section. Click **New App** to start the form. + +![Create a new App within the Space](/docs/apps/02-space-new-app.png) + +## 3. Connect GitHub and configure the build + +Connect your GitHub account by authorizing the NaN Cloud GitHub App to the repository you want to deploy (the first time it takes you to the official installation flow on github.com). Once connected, select the repo from the list, choose the branch, and give your App a name. + +> **Mandatory requirement: Dockerfile** +> Your repository **must contain a `Dockerfile`** at the root (or at the path you configure). Without a Dockerfile we cannot build your image and the app will not deploy. This gives you full control over the runtime, dependencies, and processes that start inside your app. + +If your app is an HTTP service (web page, API, admin panel, etc.), check **Expose over HTTP** and specify the **port** your app listens on internally. For example, if you start with `node server.js` listening on `:8080`, put `8080` here. We'll handle publishing it on a public domain with HTTPS. + +If your app is a process that doesn't need to be accessible from outside (a worker, a cron, a queue consumer, etc.), uncheck *Expose over HTTP*: the app will start in worker mode, without a public URL. + +![App creation form: GitHub + Dockerfile + port](/docs/apps/03-new-app-form.png) + +The **Environment variables** block (optional) lets you add both runtime and build variables. And in **Advanced options** you can adjust replicas, CPU/memory, and add persistent storage if your app needs to save state. + +Click **Deploy**. On the App detail screen you'll see the build in real-time. After the build, if everything went well, you'll see the status change to `Running`. + +## 4. Open your App + +When the status is `Running`, click the **Open** button in the top right. It opens your app's public URL in a new tab. + +![App in Running state with Open button](/docs/apps/04-app-running.png) + +From the same screen you have access to your app's live logs, events, metrics (CPU, memory, disk), environment variable management, and a settings panel to mutate branch, Dockerfile, port, and resources on the fly. + +## 5. Your app, in production + +That's it. Your GitHub repository is serving real traffic from a public domain with HTTPS, on our infrastructure. Each `git push` to the configured branch (with auto-deploy enabled) triggers a new build automatically. + +![Example of a deployed and served App](/docs/apps/05-app-example.png) + +> **Your App is live.** +> With these 5 steps you already have your app deployed. If you need to scale (more resources, more replicas, persistent storage, more Spaces to separate dev/staging/prod environments), you can do so at any time from the dashboard. Apps and Spaces are in **Beta** — if you find any issues, report them in `#support` on Discord. diff --git a/src/content/docs-es/examples.md b/src/content/docs-es/examples.md new file mode 100644 index 0000000..8730dcc --- /dev/null +++ b/src/content/docs-es/examples.md @@ -0,0 +1,644 @@ +--- +title: Examples +description: Code snippets to connect to the NaN API with Python, Node.js, curl, and more. +order: 4 +group: Guides +translated: false +--- + +# Code snippets. + +Examples to connect to the API with different languages and tools. Use `https://api.nan.builders/v1` as base URL and your personal API key. + +## model: qwen3.6 + +text generation and chat + +### curl + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-your-key-here" \ + -d '{ + "model": "qwen3.6", + "messages": [{"role": "user", "content": "Hello, how are you?"}], + "max_tokens": 500 + }' +``` + +### python (openai) + +```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": "Write a hello world in Rust"}], + max_tokens=500, + stream=True +) + +for chunk in response: + content = chunk.choices[0].delta.content + if content: + print(content, end="", flush=True) +``` + +Install: `pip install openai` + +### node.js (openai) + +```javascript +import OpenAI from "openai"; + +const client = new OpenAI({ + apiKey: "sk-your-key-here", + baseURL: "https://api.nan.builders/v1", +}); + +const stream = await client.chat.completions.create({ + model: "qwen3.6", + messages: [{ role: "user", content: "Write a hello world in Zig" }], + max_tokens: 500, + stream: true, +}); + +for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content; + if (content) process.stdout.write(content); +} +``` + +Install: `npm install openai` + +### opencode.json (config) + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "nan": { + "npm": "@ai-sdk/openai-compatible", + "name": "NaN", + "options": { + "baseURL": "https://api.nan.builders/v1", + "apiKey": "sk-your-key-here" + }, + "models": { + "qwen3.6": { + "name": "Qwen 3.6", + "contextWindow": 262144, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + }, + "gemma4": { + "name": "Gemma 4", + "contextWindow": 262144, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + }, + "deepseek-v4-flash": { + "name": "DeepSeek V4 Flash", + "contextWindow": 500000, + "modalities": { + "input": ["text"], + "output": ["text"] + } + }, + "mimo-v2.5": { + "name": "Xiaomi MiMo V2.5", + "contextWindow": 500000, + "modalities": { + "input": ["text", "image", "audio"], + "output": ["text"] + } + } + } + } + }, + "compaction": { + "auto": true, + "prune": true, + "reserved": 50000 + } +} +``` + +This is the config to connect IDEs (Cursor, OpenCode) with the 4 available LLM models: `qwen3.6`, `gemma4`, `deepseek-v4-flash` and `mimo-v2.5`. + +### .pi/agent/models.json (config) + +```json +{ + "providers": { + "nan": { + "baseUrl": "https://api.nan.builders/v1", + "api": "openai-completions", + "apiKey": "", + "compat": { + "supportsDeveloperRole": true + }, + "models": [ + { + "id": "qwen3.6", + "name": "Qwen 3.6", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 16384 + }, + { + "id": "gemma4", + "name": "Gemma 4", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 16384 + } + ] + } + } +} +``` + +Config for `~/.pi/agent/models.json` + +### .pi/agent/settings.json (config) + +```json +{ + "defaultProvider": "nan", + "defaultModel": "qwen3.6" +} +``` + +Config for `~/.pi/agent/settings.json`. Without `defaultProvider` and `defaultModel`, Pi uses its default provider and returns an authentication error (401). + +### openclaw.json (config) + +```json +{ + "models": { + "providers": { + "nan": { + "baseUrl": "https://api.nan.builders/v1", + "apiKey": "sk-...", + "api": "openai-completions", + "models": [ + { + "id": "qwen3.6", + "name": "Qwen 3.6", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 65536 + } + ] + } + } + }, + "agents": { + "defaults": { + "model": { "primary": "nan/qwen3.6" }, + "models": { + "nan/qwen3.6": { + "params": { + "maxTokens": 16000 + } + } + } + } + } +} +``` + +Config for `~/.openclaw/openclaw.json` + +`maxTokens: 65536` is the maximum the model supports. `params.maxTokens: 16000` is what is sent per request. 16K is a good balance for most tasks. If you need longer responses, increase it — but keep in mind that reasoning also consumes from that budget. + +

settings.json (Zed)

+ +```json +{ + "language_models": { + "openai": { + "api_url": "https://api.nan.builders/v1", + "available_models": [ + { + "name": "qwen3.6", + "display_name": "NaN", + "max_tokens": 262144 + } + ] + } + }, + "edit_predictions": { + "open_ai_compatible_api": { + "api_url": "https://api.nan.builders/v1", + "model": "qwen3.6" + } + } +} +``` + +Config for `~/.config/zed/settings.json` — includes inline predictions. + +## model: qwen3-embedding + +vector embeddings + +### curl + +```bash +curl https://api.nan.builders/v1/embeddings \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-your-key-here" \ + -d '{ + "model": "qwen3-embedding", + "input": ["Hello world", "Hola mundo"], + "encoding_format": "float" + }' +# → 4096-dimensional vectors per input +``` + +### python + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-your-key-here", + base_url="https://api.nan.builders/v1" +) + +response = client.embeddings.create( + model="qwen3-embedding", + input=["Kubernetes pod scheduling", "Pod scheduling in Kubernetes"], + encoding_format="float" +) + +embeddings = [d.embedding for d in response.data] +print(len(embeddings[0])) // 4096 +``` + +### node.js + +```javascript +import OpenAI from "openai"; + +const client = new OpenAI({ + apiKey: "sk-your-key-here", + baseURL: "https://api.nan.builders/v1", +}); + +const response = await client.embeddings.create({ + model: "qwen3-embedding", + input: ["Hello world", "Hola mundo"], + encoding_format: "float", +}); + +const embeddings = response.data.map((d) => d.embedding); +console.log(embeddings[0].length); // 4096 +``` + +## model: rerank + +semantic reranking — completes the RAG stack + +### curl + +```bash +curl https://api.nan.builders/v1/rerank \ + -H "Authorization: Bearer $NAN_API_KEY" \ + -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." + ] + }' +# → results[] ordered by relevance_score desc, with original index +``` + +### python + +```python +import os +from openai import OpenAI + +client = OpenAI( + api_key=os.environ["NAN_API_KEY"], + base_url="https://api.nan.builders/v1" +) + +# The /rerank endpoint is not part of the standard OpenAI client, +# but we can invoke it with client.post(). +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(f"{r['index']}: {r['relevance_score']:.3f}") +``` + +Also works with raw `requests` or any HTTP client — the endpoint is OpenAI-compatible in authentication and payload format. + +## model: kokoro + +text-to-speech + +### curl + +```bash +curl https://api.nan.builders/v1/audio/speech \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-your-key-here" \ + -d '{ + "model": "kokoro", + "input": "Welcome to NaN builders.", + "voice": "af_heart" + }' \ + -o speech.mp3 + +# English female voice (af_heart), Spanish (ef_dora), etc. +# See all voices: https://github.com/hexgrad/Kokoro-82M +``` + +### python + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-your-key-here", + base_url="https://api.nan.builders/v1" +) + +response = client.audio.speech.create( + model="kokoro", + voice="af_heart", + input="Hello, welcome to NaN builders.", + speed=1.0, + response_format="mp3" +) + +response.stream_to_file("output.mp3") + +# Spanish voice +response = client.audio.speech.create( + model="kokoro", + voice="ef_dora", + input="Hola, bienvenido a NaN builders.", + response_format="mp3" +) +``` + +### node.js + +```javascript +import OpenAI from "openai"; +import fs from "fs"; + +const client = new OpenAI({ + apiKey: "sk-your-key-here", + baseURL: "https://api.nan.builders/v1", +}); + +const response = await client.audio.speech.create({ + model: "kokoro", + voice: "af_heart", + input: "Hello, welcome to NaN builders.", + speed: 1.0, + response_format: "mp3", +}); + +const buffer = Buffer.from(await response.arrayBuffer()); +fs.writeFileSync("output.mp3", buffer); +``` + +## model: whisper + +speech-to-text + +### curl + +```bash +# Transcribe audio file +curl https://api.nan.builders/v1/audio/transcriptions \ + -H "Authorization: Bearer sk-your-key-here" \ + -F "model=whisper" \ + -F "file=@recording.mp3" \ + -F "language=en" + +# → {"text":"Transcribed text","language":"en","duration":5.2} + +# Translate to English +curl https://api.nan.builders/v1/audio/translations \ + -H "Authorization: Bearer sk-your-key-here" \ + -F "model=whisper" \ + -F "file=@recording.mp3" +``` + +### python + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-your-key-here", + base_url="https://api.nan.builders/v1" +) + +# Transcribe English audio +with open("recording.mp3", "rb") as f: + result = client.audio.transcriptions.create( + model="whisper", + file=f, + language="en", + response_format="verbose_json" + ) + +print(result.text) # Transcribed text +print(result.language) # "en" +print(result.duration) # 5.2 (seconds) + +# Translate to English +with open("recording.mp3", "rb") as f: + translation = client.audio.translations.create( + model="whisper", + file=f + ) +print(translation.text) # English translation +``` + +### node.js + +```javascript +import OpenAI from "openai"; +import fs from "fs"; +import FormData from "form-data"; + +const client = new OpenAI({ + apiKey: "sk-your-key-here", + baseURL: "https://api.nan.builders/v1", +}); + +// Transcribe audio +const file = fs.createReadStream("recording.mp3"); +const form = FormData(); +form.append("file", file); + +const result = await client.audio.transcriptions.create({ + model: "whisper", + file, + language: "en", + response_format: "verbose_json", +}); + +console.log(result.text); // Transcribed text +console.log(result.language); // "en" +console.log(result.duration); // 5.2 +``` + +## model: mimo-v2.5 + +omnimodal — chat, vision, and audio + +### curl + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-your-key-here" \ + -d '{ + "model": "mimo-v2.5", + "messages": [{"role": "user", "content": "Hello, how are you?"}], + "max_tokens": 500 + }' +``` + +With reasoning enabled, `max_tokens ≥ 300` is recommended to leave room for reasoning. + +### vision (curl) + +```bash +curl https://api.nan.builders/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-your-key-here" \ + -d '{ + "model": "mimo-v2.5", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/photo.jpg"}} + ] + }], + "max_tokens": 500 + }' +``` + +### python (openai) + +```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="mimo-v2.5", + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image."}, + {"type": "image_url", "image_url": {"url": "https://example.com/photo.jpg"}} + ] + }], + max_tokens=500 +) + +print(response.choices[0].message.content) +``` + +## tool: web search + +authenticated web search for agents — `POST /v1/search` + +### curl + +```bash +curl https://api.nan.builders/v1/search \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-your-key-here" \ + -d '{ + "query": "latest go release", + "count": 5, + "freshness": "pw" + }' +# → {"results":[{"title":...,"url":...,"snippet":...,"source":...}],"cached":false} +``` + +### python + +```python +import os +from openai import OpenAI + +client = OpenAI( + api_key=os.environ["NAN_API_KEY"], + base_url="https://api.nan.builders/v1" +) + +# /search is not part of the standard OpenAI client, but we can invoke it with client.post(). +response = client.post( + path="/search", + cast_to=object, + body={ + "query": "latest go release", + "count": 5, + "freshness": "pw", + }, +) + +for r in response["results"]: + print(r["title"], "-", r["url"]) +``` + +Also works with raw `requests` or any HTTP client — send the JSON body with your Bearer key. + +## IDE Integration + +- **Cursor**: Settings → OpenAI API → Base URL: `https://api.nan.builders/v1`, API Key: your key +- **Zed**: Settings → `settings.json` → see [full config above](#qwen36-zed) +- **Cline / Continue / Aider**: Set the environment variables: + +```bash +export OPENAI_BASE_URL="https://api.nan.builders/v1" +export OPENAI_API_KEY="sk-your-key-here" +``` diff --git a/src/content/docs-es/getting-started.md b/src/content/docs-es/getting-started.md new file mode 100644 index 0000000..daac228 --- /dev/null +++ b/src/content/docs-es/getting-started.md @@ -0,0 +1,40 @@ +--- +title: Getting Started +description: Configure your favorite IDE or tool to connect to NaN models. +order: 1 +group: Get started +translated: false +--- + +# Getting Started. + +Access is via LiteLLM with an OpenAI-compatible API. Works with any tool that accepts a `base URL` + `API key`: Cursor, Cline, Continue, Aider, Open Code, Open WebUI, or any OpenAI-compatible SDK. + +## Get your API Key + +You must be a NaN community member. If you're already subscribed, generate your API Key from the user settings section under "API Keys" on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. + +> **Note** +> Support is for technical issues only. + +## Configure your tool + +| Field | Value | +|---|---| +| base URL | `https://api.nan.builders/v1` | +| API Key | `sk-your-key-here` | +| Model | `qwen3.6` | + +OpenAI-compatible configuration example: + +```json +provider: { + openai: { + npm: "@ai-sdk/openai", + name: "NaN", + apiKey: "sk-your-key-here", + baseURL: "https://api.nan.builders/v1", + model: "qwen3.6" + } +} +``` diff --git a/src/content/docs-es/intro.md b/src/content/docs-es/intro.md new file mode 100644 index 0000000..9bc9a0e --- /dev/null +++ b/src/content/docs-es/intro.md @@ -0,0 +1,28 @@ +--- +title: Introduction +description: Connect your favorite tools (OpenCode, Cursor, Cline, etc.) to our shared inference cluster. +order: 0 +group: Get started +translated: false +--- + +# Welcome to NaN. + +This doc explains how to connect your tools to our GPUs. The cluster runs open models with an OpenAI-compatible API. If something accepts a `base URL` + `API key`, it works with NaN. + +> **To get your API Key** +> You must be a NaN community member. You can generate your API Key from the user settings section under "API Keys" on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. + +## Rate limits + +| Metric | Value | +|---|---| +| Requests per minute | 60 rpm | +| Max parallel | 5 concurrent | + +## What to do next + +- [Getting Started](/docs/getting-started): endpoint, auth, and step-by-step setup. +- [Models](/docs/models): capabilities and limits of the models. +- [Examples](/docs/examples): snippets in Python, Node.js, and curl. +- Support: report issues via `#support` on Discord. diff --git a/src/content/docs-es/models.mdx b/src/content/docs-es/models.mdx new file mode 100644 index 0000000..08def1d --- /dev/null +++ b/src/content/docs-es/models.mdx @@ -0,0 +1,258 @@ +--- +title: Models +description: Technical specifications, capabilities, and parameters of the shared cluster models. +order: 3 +group: Reference +translated: false +--- + +import ModelCard from '../../components/docs/ModelCard.astro'; +import LimitationsCard from '../../components/docs/LimitationsCard.astro'; +import RateLimits from '../../components/docs/RateLimits.astro'; + +# Cluster Models. + +Community models. All are accessed via the same OpenAI-compatible API +with the same `base URL`. + + + +max_tokens ≥ 300)', + 'Vision (image input)', + 'Audio (audio input)', + '1M token context', + 'Streaming generation (SSE)', + ]} +/> + + + + + + + + + + + +af_heart — English (female)', + 'ef_dora — Spanish (female)', + 'em_alex — Spanish (male)', + '67 voice packs total (see full list)', + ]} +/> + + + + 2 min duration', + body: 'Whisper processes on CPU at ~1x realtime. For audios longer than ~2 minutes, the proxy may return a 524 (timeout) error before transcription completes. Use compressed formats like OGG/Opus and split long files into ≤ 2 minute segments to avoid this.', + }, + { + title: 'Recommended formats', + body: 'OGG/Opus and MP3 — smaller files, same transcription quality. A 60-minute audio in OGG/Opus at 48 kbps takes ~20 MB vs ~550 MB in WAV.', + }, + ]} +/> + +/v1/images/generations)', + 'Image-to-image with up to 4 references (/v1/images/edits)', + 'Output as temporary URL (R2, ~60 min) or base64', + 'Reproducibility via seed and guidance control', + ]} +/> + + diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro index ab9ea68..3533c03 100644 --- a/src/layouts/Docs.astro +++ b/src/layouts/Docs.astro @@ -23,11 +23,47 @@ import { interface Props { title: string; description?: string; + lang?: 'en' | 'es'; + /** False on a Spanish page whose text is still the English original. */ + translated?: boolean; } // The documentation exists in English only (the MDX collection is not // translated), so the page language, the default copy and the social card are. -const { title, description = 'NaN docs. Connect to our open models through an OpenAI-compatible API.' } = Astro.props; +const { + title, + description = lang === 'es' + ? 'Documentación de NaN. Conecta con nuestros modelos abiertos a través de una API compatible con OpenAI.' + : 'NaN docs. Connect to our open models through an OpenAI-compatible API.', + lang = 'en', + translated = true, +} = Astro.props; + +const pfx = lang === 'es' ? '/es' : ''; +const T = { + en: { + onThisPage: 'On this page', + search: 'Search docs…', + skip: 'Skip to content', + prev: '← Previous', + next: 'Next →', + docs: 'Docs', + close: 'Close menu', + untranslated: + 'This page is not translated yet, so it is shown in English.', + }, + es: { + onThisPage: 'En esta página', + search: 'Buscar en la documentación…', + skip: 'Saltar al contenido', + prev: '← Anterior', + next: 'Siguiente →', + docs: 'Docs', + close: 'Cerrar menú', + untranslated: + 'Esta página todavía no está traducida, así que se muestra en inglés.', + }, +}[lang]; const siteUrl = 'https://nan.builders'; @@ -37,7 +73,7 @@ const canonical = `${siteUrl}${Astro.url.pathname.replace(/\/+$/, '') || '/'}`; const ogImage = `${siteUrl}/og/og-en.png`; const socialTitle = `${title} · NaN Docs`; -const entries = await getCollection('docs'); +const entries = await getCollection(lang === 'es' ? 'docsEs' : 'docs'); /* * The API reference does not come from the collection: Scalar serves /docs/api @@ -47,14 +83,14 @@ const entries = await getCollection('docs'); */ const navItems: DocsNavItem[] = [ ...entries.map((entry) => ({ - slug: entry.id === 'intro' ? '/docs' : `/docs/${entry.id}`, + slug: entry.id === 'intro' ? `${pfx}/docs` : `${pfx}/docs/${entry.id}`, label: entry.data.title, order: entry.data.order, group: entry.data.group, description: entry.data.description, })), { - slug: `/docs/${API_DOC_SLUG}`, + slug: `${pfx}/docs/${API_DOC_SLUG}`, label: API_DOC_META.title, order: API_DOC_META.order, group: API_DOC_META.group, @@ -64,7 +100,10 @@ const navItems: DocsNavItem[] = [ const navGroups = groupDocsNav(navItems); -const here = Astro.url.pathname.replace(/\/+$/, '') || '/docs'; +const enPath = Astro.url.pathname.replace(/^\/es(?=\/|$)/, '') || '/docs'; +const esPath = `/es${enPath}`; + +const here = Astro.url.pathname.replace(/\/+$/, '') || `${pfx}/docs`; const isActive = (slug: string) => isActiveDocPath(here, slug); /* @@ -72,11 +111,13 @@ const isActive = (slug: string) => isActiveDocPath(here, slug); * its structure dropped them; they are restored because under /docs they are * the only clue to where you are when you arrive from a search engine. */ -const breadcrumb = Astro.url.pathname +const breadcrumb = enPath .split('/') .filter(Boolean) .map((part, i, parts) => { - const path = '/' + parts.slice(0, i + 1).join('/'); + // Built from the locale-stripped path: counting `/es` as a segment made the + // Spanish pages show an extra crumb for the docs index. + const path = `${pfx}/` + parts.slice(0, i + 1).join('/'); const item = navItems.find((n) => n.slug === path); return { label: item ? item.label : part.replace(/-/g, ' '), @@ -103,7 +144,7 @@ const collectionIndex = await Promise.all( const { headings } = await render(entry); return { title: entry.data.title, - href: entry.id === 'intro' ? '/docs' : `/docs/${entry.id}`, + href: entry.id === 'intro' ? `${pfx}/docs` : `${pfx}/docs/${entry.id}`, description: entry.data.description, headings: headings .filter((h) => h.depth >= 2 && h.depth <= 3) @@ -116,7 +157,7 @@ const searchData = [ ...collectionIndex, { title: API_DOC_META.title, - href: `/docs/${API_DOC_SLUG}`, + href: `${pfx}/docs/${API_DOC_SLUG}`, description: API_DOC_META.description, headings: apiSearchHeadings(spec), }, @@ -124,7 +165,7 @@ const searchData = [ --- - + @@ -134,6 +175,10 @@ const searchData = [ + {/* Every guide exists at both paths, so each declares the other. */} + + + @@ -143,7 +188,7 @@ const searchData = [ - + @@ -161,9 +206,9 @@ const searchData = [ {socialTitle} - + - + @@ -176,7 +221,7 @@ const searchData = [ going back to the hamburger, none of which are obvious affordances on a small screen. */} -