diff --git a/src/content/docs-es/examples.md b/src/content/docs-es/examples.md index 6ecb46e5..96144d42 100644 --- a/src/content/docs-es/examples.md +++ b/src/content/docs-es/examples.md @@ -78,7 +78,7 @@ Instalación: `npm install openai` ### salida estructurada en deepseek-v4-flash -`deepseek-v4-flash` rechaza `response_format` `json_schema` con un `400`, y `json_object` solo garantiza JSON válido, no su forma. Para obtener una salida que siga un esquema, define una única herramienta de tipo función cuyo `parameters` sea tu JSON Schema (raíz `"type": "object"`, `"strict": true`) y fuérzala con `tool_choice`. El modelo devuelve argumentos que siguen el esquema en `choices[0].message.tool_calls[0].function.arguments`, como una cadena JSON. `strict` pide que se ajusten exactamente; se aplica donde el modelo admite decodificación estricta, así que valida los argumentos si tu código depende de ellos. +En `api.nan.builders`, `deepseek-v4-flash` rechaza `response_format` `json_schema` con un `400` (en `api.helmcode.com` se acepta), y `json_object` solo garantiza JSON válido, no su forma. Para obtener una salida que siga un esquema, define una única herramienta de tipo función cuyo `parameters` sea tu JSON Schema (raíz `"type": "object"`, `"strict": true`) y fuérzala con `tool_choice`. El modelo devuelve argumentos que siguen el esquema en `choices[0].message.tool_calls[0].function.arguments`, como una cadena JSON. `strict` pide que se ajusten exactamente; se aplica donde el modelo admite decodificación estricta, así que valida los argumentos si tu código depende de ellos. ```bash curl https://api.nan.builders/v1/chat/completions \ diff --git a/src/content/docs-es/models.mdx b/src/content/docs-es/models.mdx index ad5049e4..3b036f8a 100644 --- a/src/content/docs-es/models.mdx +++ b/src/content/docs-es/models.mdx @@ -33,7 +33,7 @@ OpenAI y la misma `base URL`. ]} items={[ 'Tool calling (function calling)', - 'Modo razonamiento (traza de razonamiento)', + 'Control del razonamiento (reasoning_effort: de minimal a max; siempre razona, none se aplica como minimal) · detalles', 'Visión (entrada de imagen)', 'Audio (entrada de audio)', 'Vídeo (entrada de vídeo)', @@ -50,7 +50,7 @@ OpenAI y la misma `base URL`. tag="305B MoE" leftLabel="generación de texto, chat y visión" rightLabel="capacidades" - description="Modelo MoE de 305B parámetros, servido en su variante Vision-Exp: acepta imágenes de entrada. Contexto de 1M tokens. Tool calling y razonamiento. Cuota de 3B tokens al mes por miembro. Salida estructurada: response_format json_object es compatible (el prompt debe contener la palabra JSON; si no, se rechaza con un 400), json_schema no y se rechaza con un 400. json_object solo garantiza JSON sintácticamente válido, no su forma: describe el esquema en el prompt. Para salida restringida a un esquema, usa qwen3.6 o gemma4, donde json_schema con strict restringe campos, tipos y claves obligatorias; o, en este modelo, fuerza una única herramienta de tipo función cuyo parameters sea tu esquema (ver Ejemplos). Un max_tokens por debajo de 16384 se sube a 16384 para que quepa el razonamiento, así que un prompt a menos de 16384 tokens de la ventana de 1.048.576 se rechaza con un 400 aunque pidas un max_tokens pequeño (normalmente Context length exceeded, a veces un Invalid request genérico)." + description={'Modelo MoE de 305B parámetros, servido en su variante Vision-Exp: acepta imágenes de entrada. Contexto de 1M tokens. Tool calling y razonamiento. Cuota de 3B tokens al mes por miembro. La salida estructurada depende del host: en api.helmcode.com se aceptan response_format json_schema y json_object; en api.nan.builders, json_object es compatible (el prompt debe contener la palabra JSON; si no, se rechaza con un 400) y json_schema se rechaza con un 400. json_object solo garantiza JSON sintácticamente válido, no su forma: describe el esquema en el prompt. Para salida restringida a un esquema en api.nan.builders, usa qwen3.6 o gemma4, donde json_schema con strict restringe campos, tipos y claves obligatorias; o, en este modelo, fuerza una única herramienta de tipo función cuyo parameters sea tu esquema (ver Ejemplos). Con el razonamiento activado, un max_tokens por debajo de 16384 se sube a 16384 para que quepa el razonamiento, así que un prompt a menos de 16384 tokens de la ventana de 1.048.576 se rechaza con un 400 aunque pidas un max_tokens pequeño (normalmente Context length exceeded, a veces un Invalid request genérico). Con el razonamiento apagado (reasoning_effort "none" o cualquiera de sus alias) tu max_tokens se usa tal cual.'} specs={[ { label: 'Tipo', value: 'MoE (305B total)' }, { label: 'Cuantización', value: 'FP8' }, @@ -61,9 +61,9 @@ OpenAI y la misma `base URL`. ]} items={[ 'Tool calling', - 'Modo razonamiento', + 'Control del razonamiento (reasoning_effort: none lo apaga · de minimal a max) · detalles', 'Visión (entrada de imagen)', - 'Modo JSON (json_object, el prompt debe mencionar JSON) · json_schema no soportado (400)', + 'Modo JSON (json_object; en api.nan.builders el prompt debe mencionar JSON) · json_schema: 400 en api.nan.builders, aceptado en api.helmcode.com', 'Contexto de 1M tokens', 'Generación en streaming (SSE)', ]} @@ -87,7 +87,7 @@ OpenAI y la misma `base URL`. ]} items={[ 'Tool calling (function calling)', - 'Modo razonamiento', + 'Control del razonamiento (reasoning_effort: de minimal a max; siempre razona, none se aplica como minimal) · detalles', 'Visión (entrada de imagen)', 'Contexto de 1M tokens', 'Generación en streaming (SSE)', @@ -112,7 +112,7 @@ OpenAI y la misma `base URL`. ]} items={[ 'Tool calling (formato XML)', - 'Modo razonamiento (activado por defecto)', + 'Razonamiento activado por defecto (reasoning_effort: none lo apaga · de minimal a max) · detalles', 'Visión (entrada de imagen)', 'Contexto de 1M tokens', 'Generación en streaming (SSE)', @@ -134,7 +134,7 @@ OpenAI y la misma `base URL`. ]} items={[ 'Tool calling (function calling)', - 'Modo razonamiento (se recomienda max_tokens ≥ 300)', + 'Control del razonamiento (reasoning_effort: none lo apaga · de low a max; se recomienda max_tokens ≥ 300) · detalles', 'Visión (entrada de imagen)', 'Audio (entrada de audio)', 'Contexto de 1M tokens', @@ -158,7 +158,7 @@ OpenAI y la misma `base URL`. ]} items={[ 'Tool calling (formato XML)', - 'Modo razonamiento', + 'Control del razonamiento (reasoning_effort: none o minimal lo apagan · de low a max) · detalles', 'Multimodal (visión / imágenes)', 'Generación en streaming (SSE)', ]} @@ -182,7 +182,7 @@ OpenAI y la misma `base URL`. ]} items={[ 'Tool calling (formato XML)', - 'Modo razonamiento', + 'Control del razonamiento (reasoning_effort: none o minimal lo apagan · de low a max) · detalles', 'Multimodal (visión / imágenes)', 'Generación en streaming (SSE)', ]} @@ -331,36 +331,130 @@ OpenAI y la misma `base URL`. ## Controlar el razonamiento. -Todos los modelos de chat de arriba razonan antes de responder, y el -razonamiento llega separado de la respuesta, en `message.reasoning_content`. -Cuánto puede pensar cada modelo se elige por petición con el parámetro -`reasoning_effort`, y cada modelo lo aplica distinto: la tabla es el contrato. -Un valor que un modelo no puede aplicar nunca es un error. +Todos los modelos de chat de arriba razonan antes de responder. El +razonamiento llega separado de la respuesta: en `message.reasoning_content` en +Chat Completions y como un elemento de salida `reasoning` en Responses. Lo +controla un único parámetro, igual en todos los modelos: -| Modelo | valores de `reasoning_effort` | Qué hace | +| API | Parámetro | +| --- | --- | +| Chat Completions (`/v1/chat/completions`) | `reasoning_effort` | +| Responses (`/v1/responses`) | `reasoning: {"effort": ...}` | + +Los valores, de sin razonamiento al más profundo: `none`, `minimal`, `low`, +`medium`, `high`, `xhigh`, `max`. + +**Para apagar el razonamiento, envía `none`.** No hace falta nada más. +Funciona en todos los modelos salvo GLM, que no puede dejar de razonar (ver la +tabla). + +El código escrito para otro proveedor sigue funcionando: también aceptamos +`thinking: {"type": "disabled"}`, `reasoning: {"enabled": false}`, +`enable_thinking: false` y `chat_template_kwargs: {"enable_thinking": false}`, +en el primer nivel del cuerpo o dentro de `extra_body`. Cada uno significa +exactamente `none`. Si una petición lleva un valor y además uno de estos, gana +el valor. + +| Modelo | Para apagar (`none`) | El resto de valores | | --- | --- | --- | -| `glm5.3` · `glm5.3-flash` | `low`, `medium`, `high`, `max` | Control total. Valores más altos dejan que el modelo razone más antes de responder; `max` es el más profundo. | -| `qwen3.6` | `none`, `minimal`, `low`, `medium`, `high`, `max` | `none` y `minimal` se saltan por completo la fase de razonamiento. Los otros cuatro la acotan: low 2.048, medium 8.192, high 16.384, max 32.768 tokens. | -| `gemma4` | `none`, `minimal`, `low`, `medium`, `high`, `max` | Igual que `qwen3.6`: apagado, o un presupuesto de razonamiento entre 2.048 y 32.768 tokens. | -| `deepseek-v4-flash` | cualquier valor (sin efecto) | El modelo decide por petición cuánto razona; el parámetro nunca cambia eso. | -| `qwen3.8-flash` · `mimo-v2.6-flash` | aceptado, profundidad no ajustable | El parámetro se acepta y nunca se rechaza, pero estos modelos gestionan su propia profundidad de razonamiento. | +| `deepseek-v4-flash` | Apagado. Sin tokens de razonamiento. | De `minimal` a `max` orientan cuánto razona. Son indicaciones que el modelo sigue, no topes estrictos. | +| `qwen3.8-flash` | Apagado. Sin tokens de razonamiento. | El razonamiento está activado por defecto; de `minimal` a `max` orientan su profundidad (indicaciones, no topes). | +| `mimo-v2.6-flash` | Apagado. Sin tokens de razonamiento. | `low`, `medium`, `high`, `max`. `minimal` se aplica como `low` y `xhigh` como `max`. | +| `qwen3.6` · `gemma4` | Apagado. `minimal` también lo apaga. | En Chat Completions acotan la fase de razonamiento: `low` 2.048, `medium` 8.192, `high` 16.384, `xhigh` y `max` 32.768 tokens. En Responses solo hay encendido y apagado: cualquier valor que no sea `none` o `minimal` razona con el presupuesto por defecto de 16.384 tokens. | +| `glm5.3` · `glm5.3-flash` | No es posible: GLM siempre razona. `none` se aplica como `minimal`, su razonamiento más corto (unos pocos tokens). | De `minimal` a `max`; los valores más altos razonan más antes de responder. | -Sin parámetro, cada modelo usa su propio valor por defecto (razonamiento -activo en `qwen3.6` y `gemma4`, con presupuesto de 16.384 tokens). Más -razonamiento cuesta latencia y cuenta dentro de `max_tokens`; nunca cuesta -configuración adicional. +Sin parámetro, todos los modelos razonan (`qwen3.6` y `gemma4` con un +presupuesto de 16.384 tokens). El razonamiento cuesta latencia y sus tokens +cuentan dentro de `max_tokens`. Usa solo los valores de esta página. +Cualquier otro valor se envía tal cual y queda fuera de este contrato: según el modelo, +se rechaza con un `400` o se ignora. ```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer $NAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "glm5.3-flash", - "reasoning_effort": "low", + "model": "deepseek-v4-flash", + "reasoning_effort": "none", "messages": [{"role": "user", "content": "Write a one-line summary of the CAP theorem."}] }' ``` +En Helmcode, usa `https://api.helmcode.com/v1` como base URL; todo lo demás es idéntico. + +Con el SDK de OpenAI, en Chat Completions y en Responses: + +```python +# pip install openai +import os +from openai import OpenAI + +client = OpenAI( + api_key=os.environ["NAN_API_KEY"], + base_url="https://api.nan.builders/v1", # Helmcode: https://api.helmcode.com/v1 +) + +chat = client.chat.completions.create( + model="deepseek-v4-flash", + reasoning_effort="none", + messages=[{"role": "user", "content": "Write a one-line summary of the CAP theorem."}], +) +print(chat.choices[0].message.content) + +resp = client.responses.create( + model="deepseek-v4-flash", + reasoning={"effort": "none"}, + input="Write a one-line summary of the CAP theorem.", +) +print(resp.output_text) +``` + +Si tu versión del SDK de Python todavía no conoce el parámetro (lanza `TypeError: ... unexpected keyword argument`), envíalo dentro de `extra_body`; llega a la API exactamente igual: + +```python +chat = client.chat.completions.create( + model="deepseek-v4-flash", + extra_body={"reasoning_effort": "none"}, + messages=[{"role": "user", "content": "Write a one-line summary of the CAP theorem."}], +) +``` + +En Node, con los mismos nombres que la API HTTP: + +```javascript +// npm install openai +import OpenAI from 'openai'; + +const client = new OpenAI({ + apiKey: process.env.NAN_API_KEY, + baseURL: 'https://api.nan.builders/v1', // Helmcode: https://api.helmcode.com/v1 +}); + +const chat = await client.chat.completions.create({ + model: 'deepseek-v4-flash', + reasoning_effort: 'none', + messages: [{ role: 'user', content: 'Write a one-line summary of the CAP theorem.' }], +}); +console.log(chat.choices[0].message.content); + +const resp = await client.responses.create({ + model: 'deepseek-v4-flash', + reasoning: { effort: 'none' }, + input: 'Write a one-line summary of the CAP theorem.', +}); +console.log(resp.output_text); +``` + +TypeScript: si tu versión del SDK tipa el parámetro como una unión a la que le falta un valor que envías (por ejemplo `max`), añade `// @ts-expect-error` en la línea de encima, o haz un cast del objeto de parámetros. La API acepta todos los valores de la tabla. + +```typescript +const chat = await client.chat.completions.create({ + model: 'deepseek-v4-flash', + reasoning_effort: 'max' as any, // older SDK types do not list 'max' + messages: [{ role: 'user', content: 'Write a one-line summary of the CAP theorem.' }], +}); +``` + ## Límite de stream solo-razonamiento. Los prompts que exigen mucho razonamiento pueden dejar al modelo pensando @@ -388,7 +482,7 @@ Dos cosas que conviene saber: cualquier tool call lo desarma por el resto del turno: las respuestas que escriben sobre la marcha nunca se cortan, por largas que sean. - Si un turno se cierra así, el modelo seguía planificando cuando tocó el - techo. Bajá `reasoning_effort` donde el modelo lo soporte (tabla de + techo. Baja `reasoning_effort` donde el modelo lo soporte (tabla de arriba), o reestructurá el prompt para que empiece a escribir temprano. El límite existe para acotar un modo de fallo conocido de los modelos con diff --git a/src/content/docs/examples.md b/src/content/docs/examples.md index 504a4d3a..dae81c4f 100644 --- a/src/content/docs/examples.md +++ b/src/content/docs/examples.md @@ -78,7 +78,7 @@ Install: `npm install openai` ### structured output on deepseek-v4-flash -`deepseek-v4-flash` rejects `response_format` `json_schema` with a `400`, and `json_object` only guarantees valid JSON, not its shape. To get output that follows a schema, define one function tool whose `parameters` is your JSON Schema (root `"type": "object"`, `"strict": true`) and force it with `tool_choice`. The model returns arguments that follow the schema in `choices[0].message.tool_calls[0].function.arguments`, as a JSON string. `strict` asks for an exact match; it is enforced where the model supports strict decoding, so validate the arguments if your code depends on them. +On `api.nan.builders`, `deepseek-v4-flash` rejects `response_format` `json_schema` with a `400` (on `api.helmcode.com` it is accepted), and `json_object` only guarantees valid JSON, not its shape. To get output that follows a schema, define one function tool whose `parameters` is your JSON Schema (root `"type": "object"`, `"strict": true`) and force it with `tool_choice`. The model returns arguments that follow the schema in `choices[0].message.tool_calls[0].function.arguments`, as a JSON string. `strict` asks for an exact match; it is enforced where the model supports strict decoding, so validate the arguments if your code depends on them. ```bash curl https://api.nan.builders/v1/chat/completions \ diff --git a/src/content/docs/models.mdx b/src/content/docs/models.mdx index 3a5b39d6..2b0c2474 100644 --- a/src/content/docs/models.mdx +++ b/src/content/docs/models.mdx @@ -33,7 +33,7 @@ with the same `base URL`. ]} items={[ 'Tool calling (function calling)', - 'Reasoning control (`reasoning_effort`: low · medium · high · max)', + 'Reasoning control (reasoning_effort: minimal to max; always reasons, none is applied as minimal) · details', 'Vision (image input)', 'Audio (audio input)', 'Video (video input)', @@ -50,7 +50,7 @@ with the same `base URL`. tag="305B MoE" leftLabel="text generation, chat & vision" rightLabel="capabilities" - description="305B parameter MoE model, served as the Vision-Exp variant: it takes images as input. 1M token context. Tool calling and reasoning. 3B token monthly quota per member. Structured output: response_format json_object is supported (the prompt must contain the word JSON, otherwise it is rejected with a 400), json_schema is not and is rejected with a 400. json_object guarantees syntactically valid JSON only, not its shape: describe the schema in the prompt. For schema-constrained output, use qwen3.6 or gemma4, where json_schema with strict constrains fields, types and required keys; or, on this model, force one function tool whose parameters is your schema (see Examples). A max_tokens below 16384 is raised to 16384 so the reasoning fits, so a prompt within 16384 tokens of the 1,048,576-token window is rejected with a 400 even with a small max_tokens (usually Context length exceeded, sometimes a generic Invalid request)." + description={'305B parameter MoE model, served as the Vision-Exp variant: it takes images as input. 1M token context. Tool calling and reasoning. 3B token monthly quota per member. Structured output depends on the host: on api.helmcode.com, response_format json_schema and json_object are accepted; on api.nan.builders, json_object is supported (the prompt must contain the word JSON, otherwise it is rejected with a 400) and json_schema is rejected with a 400. json_object guarantees syntactically valid JSON only, not its shape: describe the schema in the prompt. For schema-constrained output on api.nan.builders, use qwen3.6 or gemma4, where json_schema with strict constrains fields, types and required keys; or, on this model, force one function tool whose parameters is your schema (see Examples). While reasoning is on, a max_tokens below 16384 is raised to 16384 so the reasoning fits, so a prompt within 16384 tokens of the 1,048,576-token window is rejected with a 400 even with a small max_tokens (usually Context length exceeded, sometimes a generic Invalid request). With reasoning off (reasoning_effort "none" or any of its aliases) your max_tokens is used as sent.'} specs={[ { label: 'Type', value: 'MoE (305B total)' }, { label: 'Quantization', value: 'FP8' }, @@ -61,9 +61,9 @@ with the same `base URL`. ]} items={[ 'Tool calling', - 'Reasoning mode (adaptive — not level-adjustable)', + 'Reasoning control (reasoning_effort: none turns it off · minimal to max) · details', 'Vision (image input)', - 'JSON mode (json_object, the prompt must mention JSON) · json_schema not supported (400)', + 'JSON mode (json_object; on api.nan.builders the prompt must mention JSON) · json_schema: 400 on api.nan.builders, accepted on api.helmcode.com', '1M token context', 'Streaming generation (SSE)', ]} @@ -87,7 +87,7 @@ with the same `base URL`. ]} items={[ 'Tool calling (function calling)', - 'Reasoning control (`reasoning_effort`: low · medium · high · max)', + 'Reasoning control (reasoning_effort: minimal to max; always reasons, none is applied as minimal) · details', 'Vision (image input)', '1M token context', 'Streaming generation (SSE)', @@ -112,7 +112,7 @@ with the same `base URL`. ]} items={[ 'Tool calling (XML format)', - 'Reasoning mode (on by default)', + 'Reasoning on by default (reasoning_effort: none turns it off · minimal to max) · details', 'Vision (image input)', '1M token context', 'Streaming generation (SSE)', @@ -134,7 +134,7 @@ with the same `base URL`. ]} items={[ 'Tool calling (function calling)', - 'Reasoning mode (recommended max_tokens ≥ 300)', + 'Reasoning control (reasoning_effort: none turns it off · low to max; recommended max_tokens ≥ 300) · details', 'Vision (image input)', 'Audio (audio input)', '1M token context', @@ -158,7 +158,7 @@ with the same `base URL`. ]} items={[ 'Tool calling (XML format)', - 'Reasoning control (`none` · low · medium · high · max)', + 'Reasoning control (reasoning_effort: none or minimal turns it off · low to max) · details', 'Multimodal (vision / images)', 'Streaming generation (SSE)', ]} @@ -182,7 +182,7 @@ with the same `base URL`. ]} items={[ 'Tool calling (XML format)', - 'Reasoning control (`none` · low · medium · high · max)', + 'Reasoning control (reasoning_effort: none or minimal turns it off · low to max) · details', 'Multimodal (vision / images)', 'Streaming generation (SSE)', ]} @@ -330,35 +330,128 @@ with the same `base URL`. ## Controlling reasoning. -Every chat model above thinks before it answers, and the reasoning trace -arrives separately from the answer, in `message.reasoning_content`. How much a -model is allowed to think is a request parameter, `reasoning_effort`, and each -model applies it differently — the table is the contract. A value a model -cannot apply is never an error. +Every chat model above reasons before it answers. The reasoning arrives +separately from the answer: in `message.reasoning_content` on Chat Completions, +as a `reasoning` output item on Responses. One parameter controls it, the same +on every model: -| Model | `reasoning_effort` values | What it does | +| API | Parameter | +| --- | --- | +| Chat Completions (`/v1/chat/completions`) | `reasoning_effort` | +| Responses (`/v1/responses`) | `reasoning: {"effort": ...}` | + +The values, from no reasoning to the deepest: `none`, `minimal`, `low`, +`medium`, `high`, `xhigh`, `max`. + +**To turn reasoning off, send `none`.** That is the whole switch. It works on +every model except GLM, which cannot stop reasoning (see the table). + +Code written for another provider keeps working: we also accept +`thinking: {"type": "disabled"}`, `reasoning: {"enabled": false}`, +`enable_thinking: false` and `chat_template_kwargs: {"enable_thinking": false}`, +at the top level of the body or inside `extra_body`. Each one means exactly +`none`. If a request carries both a value and one of these, the value wins. + +| Model | Off switch (`none`) | The other values | | --- | --- | --- | -| `glm5.3` · `glm5.3-flash` | `low`, `medium`, `high`, `max` | Fully controllable. Higher values let the model reason longer before it answers; `max` is the deepest. | -| `qwen3.6` | `none`, `minimal`, `low`, `medium`, `high`, `max` | `none` and `minimal` skip the reasoning phase entirely. The other four cap it: low 2,048, medium 8,192, high 16,384, max 32,768 tokens. | -| `gemma4` | `none`, `minimal`, `low`, `medium`, `high`, `max` | Same as `qwen3.6`: off, or a reasoning budget between 2,048 and 32,768 tokens. | -| `deepseek-v4-flash` | any value (no effect) | The model decides per request how much to reason; the parameter never changes that. | -| `qwen3.8-flash` · `mimo-v2.6-flash` | accepted, depth not adjustable | The parameter is accepted and never rejected, but these models manage their own reasoning depth. | +| `deepseek-v4-flash` | Off. No reasoning tokens. | `minimal` to `max` steer how much it reasons. They are hints the model follows, not hard caps. | +| `qwen3.8-flash` | Off. No reasoning tokens. | Reasoning is on by default; `minimal` to `max` steer its depth (hints, not caps). | +| `mimo-v2.6-flash` | Off. No reasoning tokens. | `low`, `medium`, `high`, `max`. `minimal` is applied as `low` and `xhigh` as `max`. | +| `qwen3.6` · `gemma4` | Off. `minimal` turns it off too. | On Chat Completions they cap the reasoning phase: `low` 2,048, `medium` 8,192, `high` 16,384, `xhigh` and `max` 32,768 tokens. On Responses only on and off apply: any value but `none` or `minimal` reasons with the default 16,384-token budget. | +| `glm5.3` · `glm5.3-flash` | Not possible: GLM always reasons. `none` is applied as `minimal`, its shortest reasoning (a few tokens). | `minimal` to `max`; higher values reason longer before answering. | -With no parameter, every model uses its own default (reasoning on for -`qwen3.6` and `gemma4`, with a 16,384-token budget). More reasoning costs -latency and counts toward `max_tokens`; it never costs extra setup. +With no parameter every model reasons (`qwen3.6` and `gemma4` with a +16,384-token budget). Reasoning costs latency and its tokens count toward +`max_tokens`. Use only the values listed here. +Any other value is passed through unchanged and is not part of this contract: depending +on the model it is rejected with a `400` or ignored. ```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer $NAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "glm5.3-flash", - "reasoning_effort": "low", + "model": "deepseek-v4-flash", + "reasoning_effort": "none", "messages": [{"role": "user", "content": "Write a one-line summary of the CAP theorem."}] }' ``` +On Helmcode, use `https://api.helmcode.com/v1` as the base URL; everything else is identical. + +With the OpenAI SDK, on Chat Completions and on Responses: + +```python +# pip install openai +import os +from openai import OpenAI + +client = OpenAI( + api_key=os.environ["NAN_API_KEY"], + base_url="https://api.nan.builders/v1", # Helmcode: https://api.helmcode.com/v1 +) + +chat = client.chat.completions.create( + model="deepseek-v4-flash", + reasoning_effort="none", + messages=[{"role": "user", "content": "Write a one-line summary of the CAP theorem."}], +) +print(chat.choices[0].message.content) + +resp = client.responses.create( + model="deepseek-v4-flash", + reasoning={"effort": "none"}, + input="Write a one-line summary of the CAP theorem.", +) +print(resp.output_text) +``` + +If your version of the Python SDK does not know the parameter yet (it raises `TypeError: ... unexpected keyword argument`), send it through `extra_body`; it reaches the API exactly the same: + +```python +chat = client.chat.completions.create( + model="deepseek-v4-flash", + extra_body={"reasoning_effort": "none"}, + messages=[{"role": "user", "content": "Write a one-line summary of the CAP theorem."}], +) +``` + +In Node, with the same names as the HTTP API: + +```javascript +// npm install openai +import OpenAI from 'openai'; + +const client = new OpenAI({ + apiKey: process.env.NAN_API_KEY, + baseURL: 'https://api.nan.builders/v1', // Helmcode: https://api.helmcode.com/v1 +}); + +const chat = await client.chat.completions.create({ + model: 'deepseek-v4-flash', + reasoning_effort: 'none', + messages: [{ role: 'user', content: 'Write a one-line summary of the CAP theorem.' }], +}); +console.log(chat.choices[0].message.content); + +const resp = await client.responses.create({ + model: 'deepseek-v4-flash', + reasoning: { effort: 'none' }, + input: 'Write a one-line summary of the CAP theorem.', +}); +console.log(resp.output_text); +``` + +TypeScript: if your SDK version types the parameter as a union that lacks a value you send (for example `max`), add `// @ts-expect-error` on the line above it, or cast the params object. The API accepts every value in the table. + +```typescript +const chat = await client.chat.completions.create({ + model: 'deepseek-v4-flash', + reasoning_effort: 'max' as any, // older SDK types do not list 'max' + messages: [{ role: 'user', content: 'Write a one-line summary of the CAP theorem.' }], +}); +``` + ## Reasoning-only stream limit. Reasoning-heavy prompts can keep a model thinking for a long time before it writes anything. When a streaming turn has produced only reasoning — no `content`, no tool calls — for **420 seconds**, the platform closes the turn instead of letting it run into a dead end. A turn that is demonstrably thinking — it has already produced more than 45,000 reasoning characters — gets until **600 seconds**. The reasoning-character budget also scales with the size of your prompt: roughly 150,000 characters of headroom plus half the prompt's size in characters, capped at 300,000. When the platform closes the turn you receive `finish_reason: "length"` on an empty delta, followed by a usage chunk. diff --git a/src/data/openapi.json b/src/data/openapi.json index 03a1c703..049f80a2 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, 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 inference 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### Platform tokens\n\nFor scripts that only manage [Runs](#tag/runs), [Automations](#tag/automations), [Secrets](#tag/secrets), [Notifications](#tag/notifications) and [Approvals](#tag/approvals), and list your [Workspaces](#tag/workspaces), create a **platform token** (`nan_pat_...`) under \"Tokens\" in your settings, and send it the same way: `Authorization: Bearer $NAN_TOKEN`. An API key works on inference and on Runs, Approvals, Workspaces and reading and running Automations; never on Secrets, Notifications or changes to automations. Each endpoint lists the credentials it accepts.\n\nA platform token carries **scopes**, and each endpoint names the one it needs: `runs` (runs and approvals), `workspaces:read`, `automations:read` (read automations, templates, triggers and deliveries), `automations:run` (run an existing automation), `automations:write` (create, change and delete automations and their triggers), `secrets:read` (read secret metadata, never values), `secrets:write` (create, replace, change and delete secrets), `notifications:read` (read notification channels and their deliveries, never their URLs) and `notifications:write` (create, change, test and delete notification channels, and rotate their signing secrets). Scopes are independent: `secrets:write` does not include `secrets:read`, and `notifications:write` does not include `notifications:read`. New tokens get `runs` and `workspaces:read`. A token without the scope gets `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This platform token does not have the \\\"automations:run\\\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"token_scope_denied\"\n }\n}\n```\n\nA platform token never works for inference: the model endpoints answer `401`:\n\n```json\n{\n \"error\": {\n \"message\": \"Platform tokens (nan_pat_) cannot be used for inference. Use an inference API key (sk-) instead. Platform tokens only work on the platform API (/v1/runs, /v1/automations, /v1/approvals, /v1/secrets, /v1/notification-channels and GET /api/workspaces).\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"token_not_valid_for_inference\"\n }\n}\n```\n\n### CLI session and the write scopes\n\nYour secrets and your automations (with their triggers) can be managed through the API and the [NaN CLI](https://nan.builders/docs/nan-cli) where the write API is available for your account (it is being opened gradually). Three credentials can change them: you signed in at [cloud.nan.builders](https://cloud.nan.builders/), the **CLI session** that `nan auth login` saves, and a platform token with `automations:write` or `secrets:write`. Secret metadata is read with a CLI session or `secrets:read`. API keys (`sk-...`), the key inside a workspace and sessions from older clients are refused:\n\n```json\n{\n \"error\": {\n \"message\": \"This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \\\"secrets:write\\\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"credential_not_allowed\"\n }\n}\n```\n\nA token with `secrets:write` lets whoever holds it replace any of your secrets. A token with `automations:write` lets whoever holds it create or change an automation that receives any of your secrets, with instructions of their choice and nothing waiting for approval: that is as good as reading every secret you have. Such a token, held by an agent or a script, can change your secrets or automations. These scopes can only be added to a token in the portal, signed in through your browser. The same goes for `notifications:read` and `notifications:write`, offered only where your account has notifications and the write API: a token with `notifications:write` decides where your notifications go. Give a token only the scopes its job needs, and run `nan auth login` on your own machine, never inside a workspace: the agents there could read the session.\n\n### Browser sign-in\n\nSome actions need you signed in through your browser at [cloud.nan.builders](https://cloud.nan.builders/), and no API key, platform token or CLI session can do them: approving a run, and creating a token with an `automations:`, `secrets:` or `notifications:` scope. Where the write API is not yet available for your account, changing automations and their triggers is also browser-only. Through the API they answer `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This action needs a browser session: sign in at cloud.nan.builders in your browser and try again from there. CLI sessions, API keys and platform tokens cannot approve runs or create tokens with automations or secrets scopes.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"browser_session_required\"\n }\n}\n```\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=\"deepseek-v4-flash\",\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. `/chat/completions` streams incrementally on every chat model. `/responses` streams incrementally on `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.6-flash` and `glm5.3`; on `qwen3.6` and `gemma4` it holds the whole answer and sends it in one burst at the end.\n\n## Rate limits\n\n{{RATE_LIMITS}}\n\nImage endpoints run on their own budget, separate from the model endpoints: 20 requests per minute and 100 requests per month. The usage endpoint is metered separately too: {{USAGE_RATE_LIMIT}} requests per minute per member. 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); content blocked by the safety filter; a `max_tokens` above what the model can generate (a generic \"Invalid request\" message today); or a request that does not fit the model's context window. On the models hosted on the cluster (`qwen3.6`, `gemma4`) the overflow message is \"Context length exceeded for model '...'\" with the model's limit; on the other models it is usually that text, but it can arrive as the generic \"Invalid request. Check your request parameters.\" | `\"400\"` (with `type: invalid_request_error`) · `content_policy_violation` |\n| `401` | Missing or invalid API key, or a key whose tier does not reach the requested model (`glm5.3`): \"This API key does not have access to the requested model\", `type: auth_error`. Measured 2026-09-12. A platform token (`nan_pat_...`) sent to an inference endpoint answers `token_not_valid_for_inference`. | `invalid_api_key` · `token_not_valid_for_inference` |\n| `402` | The token allowance is spent on a model that carries one. Not retryable: the counter returns to zero when that model's quota period does, the calendar month for the models counted per month and your billing period for `glm5.3`. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint. Image generation requires inference membership. A model your tier cannot reach answers `401`, not this. On the platform endpoints: a platform token without the scope the endpoint needs, a credential that cannot change secrets or automations (see [CLI session and the write scopes](#description/cli-session-and-the-write-scopes)), or an action that needs a [browser sign-in](#description/browser-sign-in). | `tier_restricted` · `token_scope_denied` · `credential_not_allowed` · `browser_session_required` |\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.3`, 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| `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 (a context overflow is not retried automatically either: shorten the input or pick a model with a larger window): they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either: it clears when that model's quota period resets.\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, vision, reasoning | Streaming, tool calling, reasoning, image input, 1M-token context. 3B tokens/month per member |\n| `mimo-v2.6-flash` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.8-flash` | Chat, vision, agents | Streaming, tool calling, reasoning (on by default), vision, 1M-token context. 500M tokens/month per member |\n| `glm5.3-flash` | Chat, vision, agents | Streaming, tool calling, reasoning, vision, 1M-token context. 2B 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.3` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, image, audio and video input, 1M-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| `qwen-image-2.1` | Image generation (text→image) | 512-1280 px, 1-4 per request, seed 0-2147483647. 100 images/month per member (shared pool with flux-2-klein) |\n\n`glm5.3` is served only to keys on the GLM 5.3 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, plus the two read-only [Workspaces](#tag/workspaces) endpoints under `/api/workspaces`, and 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 inference 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### Platform tokens\n\nFor scripts that only manage [Runs](#tag/runs), [Automations](#tag/automations), [Secrets](#tag/secrets), [Notifications](#tag/notifications) and [Approvals](#tag/approvals), and list your [Workspaces](#tag/workspaces), create a **platform token** (`nan_pat_...`) under \"Tokens\" in your settings, and send it the same way: `Authorization: Bearer $NAN_TOKEN`. An API key works on inference and on Runs, Approvals, Workspaces and reading and running Automations; never on Secrets, Notifications or changes to automations. Each endpoint lists the credentials it accepts.\n\nA platform token carries **scopes**, and each endpoint names the one it needs: `runs` (runs and approvals), `workspaces:read`, `automations:read` (read automations, templates, triggers and deliveries), `automations:run` (run an existing automation), `automations:write` (create, change and delete automations and their triggers), `secrets:read` (read secret metadata, never values), `secrets:write` (create, replace, change and delete secrets), `notifications:read` (read notification channels and their deliveries, never their URLs) and `notifications:write` (create, change, test and delete notification channels, and rotate their signing secrets). Scopes are independent: `secrets:write` does not include `secrets:read`, and `notifications:write` does not include `notifications:read`. New tokens get `runs` and `workspaces:read`. A token without the scope gets `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This platform token does not have the \\\"automations:run\\\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"token_scope_denied\"\n }\n}\n```\n\nA platform token never works for inference: the model endpoints answer `401`:\n\n```json\n{\n \"error\": {\n \"message\": \"Platform tokens (nan_pat_) cannot be used for inference. Use an inference API key (sk-) instead. Platform tokens only work on the platform API (/v1/runs, /v1/automations, /v1/approvals, /v1/secrets, /v1/notification-channels and GET /api/workspaces).\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"token_not_valid_for_inference\"\n }\n}\n```\n\n### CLI session and the write scopes\n\nYour secrets and your automations (with their triggers) can be managed through the API and the [NaN CLI](https://nan.builders/docs/nan-cli) where the write API is available for your account (it is being opened gradually). Three credentials can change them: you signed in at [cloud.nan.builders](https://cloud.nan.builders/), the **CLI session** that `nan auth login` saves, and a platform token with `automations:write` or `secrets:write`. Secret metadata is read with a CLI session or `secrets:read`. API keys (`sk-...`), the key inside a workspace and sessions from older clients are refused:\n\n```json\n{\n \"error\": {\n \"message\": \"This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \\\"secrets:write\\\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"credential_not_allowed\"\n }\n}\n```\n\nA token with `secrets:write` lets whoever holds it replace any of your secrets. A token with `automations:write` lets whoever holds it create or change an automation that receives any of your secrets, with instructions of their choice and nothing waiting for approval: that is as good as reading every secret you have. Such a token, held by an agent or a script, can change your secrets or automations. These scopes can only be added to a token in the portal, signed in through your browser. The same goes for `notifications:read` and `notifications:write`, offered only where your account has notifications and the write API: a token with `notifications:write` decides where your notifications go. Give a token only the scopes its job needs, and run `nan auth login` on your own machine, never inside a workspace: the agents there could read the session.\n\n### Browser sign-in\n\nSome actions need you signed in through your browser at [cloud.nan.builders](https://cloud.nan.builders/), and no API key, platform token or CLI session can do them: approving a run, and creating a token with an `automations:`, `secrets:` or `notifications:` scope. Where the write API is not yet available for your account, changing automations and their triggers is also browser-only. Through the API they answer `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This action needs a browser session: sign in at cloud.nan.builders in your browser and try again from there. CLI sessions, API keys and platform tokens cannot approve runs or create tokens with automations or secrets scopes.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"browser_session_required\"\n }\n}\n```\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=\"deepseek-v4-flash\",\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. `/chat/completions` streams incrementally on every chat model. `/responses` streams incrementally on `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.6-flash` and `glm5.3`; on `qwen3.6` and `gemma4` it holds the whole answer and sends it in one burst at the end.\n\n## Reasoning\n\nEvery chat model in the catalogue reasons before it answers, and one parameter controls it on every model in the catalogue, the same on both hosts: `reasoning_effort` on `/chat/completions`, `reasoning.effort` on `/responses`. The values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`.\n\nTo turn reasoning off, send `\"reasoning_effort\": \"none\"` (or `\"reasoning\": {\"effort\": \"none\"}` on `/responses`). `glm5.3` and `glm5.3-flash` always reason and cannot stop: there `none` is applied as `minimal`, their shortest reasoning.\n\nThe switches other providers document are accepted too and mean exactly `none`: `thinking: {\"type\": \"disabled\"}`, `reasoning: {\"enabled\": false}`, `enable_thinking: false` and `chat_template_kwargs: {\"enable_thinking\": false}`, at the top level or inside `extra_body`. If a request carries both a value and one of these, the value wins. Any other value is passed through unchanged and is not part of this contract: depending on the model it is rejected with a `400` or ignored. Models outside the catalogue take `reasoning_effort` as their own API defines it, without these aliases.\n\nWhat each value does per model: [Controlling reasoning](https://nan.builders/docs/models#controlling-reasoning).\n\n## Rate limits\n\n{{RATE_LIMITS}}\n\nImage endpoints run on their own budget, separate from the model endpoints: 20 requests per minute and 100 requests per month. The usage endpoint is metered separately too: {{USAGE_RATE_LIMIT}} requests per minute per member. 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); content blocked by the safety filter; a `max_tokens` above what the model can generate (a generic \"Invalid request\" message today); or a request that does not fit the model's context window. On the models hosted on the cluster (`qwen3.6`, `gemma4`) the overflow message is \"Context length exceeded for model '...'\" with the model's limit; on the other models it is usually that text, but it can arrive as the generic \"Invalid request. Check your request parameters.\" | `\"400\"` (with `type: invalid_request_error`) · `content_policy_violation` |\n| `401` | Missing or invalid API key, or a key whose tier does not reach the requested model (`glm5.3`): \"This API key does not have access to the requested model\", `type: auth_error`. Measured 2026-09-12. A platform token (`nan_pat_...`) sent to an inference endpoint answers `token_not_valid_for_inference`. | `invalid_api_key` · `token_not_valid_for_inference` |\n| `402` | The token allowance is spent on a model that carries one. Not retryable: the counter returns to zero when that model's quota period does, the calendar month for the models counted per month and your billing period for `glm5.3`. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint. Image generation requires inference membership. A model your tier cannot reach answers `401`, not this. On the platform endpoints: a platform token without the scope the endpoint needs, a credential that cannot change secrets or automations (see [CLI session and the write scopes](#description/cli-session-and-the-write-scopes)), or an action that needs a [browser sign-in](#description/browser-sign-in). | `tier_restricted` · `token_scope_denied` · `credential_not_allowed` · `browser_session_required` |\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.3`, 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| `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 (a context overflow is not retried automatically either: shorten the input or pick a model with a larger window): they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either: it clears when that model's quota period resets.\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, vision, reasoning | Streaming, tool calling, reasoning, image input, 1M-token context. 3B tokens/month per member |\n| `mimo-v2.6-flash` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.8-flash` | Chat, vision, agents | Streaming, tool calling, reasoning (on by default), vision, 1M-token context. 500M tokens/month per member |\n| `glm5.3-flash` | Chat, vision, agents | Streaming, tool calling, reasoning, vision, 1M-token context. 2B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (on by default; `reasoning_effort: \"none\"` turns it off) |\n| `gemma4` | Chat, vision, agents | Streaming, tool calling, vision, reasoning (on by default; `reasoning_effort: \"none\"` turns it off) |\n| `glm5.3` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, image, audio and video input, 1M-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| `qwen-image-2.1` | Image generation (text→image) | 512-1280 px, 1-4 per request, seed 0-2147483647. 100 images/month per member (shared pool with flux-2-klein) |\n\n`glm5.3` is served only to keys on the GLM 5.3 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, plus the two read-only [Workspaces](#tag/workspaces) endpoints under `/api/workspaces`, and we add capabilities without breaking existing fields.", "contact": { "name": "NaN", "url": "https://nan.builders" @@ -200,7 +200,7 @@ "max_tokens": { "type": "integer", "minimum": 1, - "description": "Maximum number of tokens to generate. A value above what the model can generate is rejected with a generic `400`. A request that does not fit the model's context window is rejected with a `400`: \"Context length exceeded for model '...'\" on `qwen3.6` and `gemma4`, usually the same text (sometimes the generic \"Invalid request\") on the other models. On `deepseek-v4-flash` a smaller value is raised to 16384 so the reasoning fits, so a prompt within 16384 tokens of its 1,048,576-token window is rejected with a `400` even with a small `max_tokens`.", + "description": "Maximum number of tokens to generate. A value above what the model can generate is rejected with a generic `400`. A request that does not fit the model's context window is rejected with a `400`: \"Context length exceeded for model '...'\" on `qwen3.6` and `gemma4`, usually the same text (sometimes the generic \"Invalid request\") on the other models. On `deepseek-v4-flash`, while reasoning is on, a smaller value is raised to 16384 so the reasoning fits, so a prompt within 16384 tokens of its 1,048,576-token window is rejected with a `400` even with a small `max_tokens`; with reasoning off (`reasoning_effort: \"none\"` or an alias) the value is used as sent.", "example": 512 }, "temperature": { @@ -246,9 +246,10 @@ "low", "medium", "high", + "xhigh", "max" ], - "description": "Reasoning depth. Per model: `glm5.3` and `glm5.3-flash` accept `low`, `medium`, `high` and `max` — deeper means the model reasons longer before answering. `qwen3.6` and `gemma4` accept `none` or `minimal` (no reasoning phase at all) and `low`, `medium`, `high`, `max`, which cap the reasoning phase at 2,048 / 8,192 / 16,384 / 32,768 tokens. `deepseek-v4-flash` reasons adaptively per request: the parameter is accepted and has no effect. The remaining chat models accept the parameter and manage their own depth. The reasoning trace arrives in `message.reasoning_content`. A value a model cannot apply is never rejected. Details: [Models](https://nan.builders/docs/models).", + "description": "How much the model reasons before answering. `none` turns reasoning off on every model in the catalogue except `glm5.3` and `glm5.3-flash`, which always reason: there `none` is applied as `minimal`. The other values deepen it: on `qwen3.6` and `gemma4` `low`, `medium`, `high` and `xhigh`/`max` cap the reasoning phase at 2,048 / 8,192 / 16,384 / 32,768 tokens; on `deepseek-v4-flash` and `qwen3.8-flash` they steer the depth; on `mimo-v2.6-flash` `minimal` is applied as `low` and `xhigh` as `max`. Without the parameter every model reasons. The reasoning trace arrives in `message.reasoning_content`. `thinking: {\"type\": \"disabled\"}`, `reasoning: {\"enabled\": false}`, `enable_thinking: false` and `chat_template_kwargs.enable_thinking: false` are accepted as aliases of `none`; this parameter wins over them. Details: [Controlling reasoning](https://nan.builders/docs/models#controlling-reasoning).", "example": "high" }, "chat_template_kwargs": { @@ -258,7 +259,7 @@ "type": "boolean" } }, - "description": "Reasoning toggle for `qwen3.6`/`gemma4`: `{ \"enable_thinking\": boolean }`. In OpenAI SDKs, pass it inside `extra_body`." + "description": "Accepted as an alias: `{ \"enable_thinking\": false }` means exactly `reasoning_effort: \"none\"`, on every model in the catalogue. Prefer `reasoning_effort`, which wins when both are sent. In OpenAI SDKs, pass it inside `extra_body`." } } }, @@ -1088,6 +1089,25 @@ "minimum": 1, "description": "The maximum number of output tokens. Default 65536 on `qwen3.6`." }, + "reasoning": { + "type": "object", + "properties": { + "effort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "example": "none" + } + }, + "description": "How much the model reasons before answering, as `{\"effort\": ...}`. `none` turns reasoning off on every model in the catalogue except `glm5.3` and `glm5.3-flash`, which always reason (there `none` is applied as `minimal`). On `qwen3.6` and `gemma4` this endpoint applies on or off only: any value but `none` or `minimal` reasons with the default budget. The same aliases as on `/chat/completions` are accepted. Details: [Controlling reasoning](https://nan.builders/docs/models#controlling-reasoning)." + }, "temperature": { "type": "number", "minimum": 0, @@ -9365,7 +9385,7 @@ } }, "ResponseFormat": { - "description": "Force structured output. What each mode guarantees:\n\n- `json_object`: syntactically valid JSON only, NOT its shape. Describe the schema you want in the prompt. On `deepseek-v4-flash` the prompt must also contain the word JSON, otherwise `400`.\n- `json_schema` with `strict: true`: the output is constrained to the schema (fields, types and required keys).\n\n`json_schema` works on `qwen3.6` and `gemma4`. On `deepseek-v4-flash` it is rejected with a `400` before the request reaches the model; `json_object` still works there. For output that follows a schema on `deepseek-v4-flash`, define one function tool whose `parameters` is your JSON Schema (root `type: object`, `strict: true`), force it with `tool_choice: {\"type\": \"function\", \"function\": {\"name\": \"...\"}}` and read `choices[0].message.tool_calls[0].function.arguments`.", + "description": "Force structured output. What each mode guarantees:\n\n- `json_object`: syntactically valid JSON only, NOT its shape. Describe the schema you want in the prompt. On `deepseek-v4-flash` on `api.nan.builders` the prompt must also contain the word JSON, otherwise `400`.\n- `json_schema` with `strict: true`: the output is constrained to the schema (fields, types and required keys).\n\n`json_schema` works on `qwen3.6` and `gemma4`. On `deepseek-v4-flash` it depends on the host: on the enterprise base URL (see the overview) it is accepted; on `api.nan.builders` it is rejected with a `400` before the request reaches the model, and `json_object` still works there. For output that follows a schema on `deepseek-v4-flash` there, define one function tool whose `parameters` is your JSON Schema (root `type: object`, `strict: true`), force it with `tool_choice: {\"type\": \"function\", \"function\": {\"name\": \"...\"}}` and read `choices[0].message.tool_calls[0].function.arguments`.", "oneOf": [ { "type": "object", diff --git a/src/lib/openapiSpec.test.ts b/src/lib/openapiSpec.test.ts index dbc69923..660aa391 100644 --- a/src/lib/openapiSpec.test.ts +++ b/src/lib/openapiSpec.test.ts @@ -673,7 +673,9 @@ describe('getting-started guides: /usage parity', () => { /** * THE GATEWAY REJECTS TWO REQUEST SHAPES WITH A 400 BEFORE ROUTING: a tool * name outside ^[a-zA-Z0-9_-]{1,64}$ (any model) and `response_format` - * `json_schema` on `deepseek-v4-flash` (`json_object` still works there). + * `json_schema` on `deepseek-v4-flash` on api.nan.builders (`json_object` still + * works there). On api.helmcode.com the same model accepts `json_schema` (and + * `json_object` without the word JSON), measured live 2026-10-15. * A member who hits either should find it in the reference and on the model * card, in both locales, not learn it from the error. */ @@ -699,12 +701,14 @@ describe('documented 400s: tool names and structured output', () => { } }); - it('says json_schema is rejected on deepseek-v4-flash and json_object works there', () => { + it('says json_schema on deepseek-v4-flash depends on the host, and json_object works on both', () => { const description: string = schemas.ResponseFormat.description; expect(description).toContain( - 'On `deepseek-v4-flash` it is rejected with a `400` before the request reaches the model; `json_object` still works there.', + 'On `deepseek-v4-flash` it depends on the host: on the enterprise base URL (see the overview) it is accepted; on `api.nan.builders` it is rejected with a `400` before the request reaches the model, and `json_object` still works there.', ); - expect(description).toContain('On `deepseek-v4-flash` the prompt must also contain the word JSON, otherwise `400`.'); + expect(description).toContain('On `deepseek-v4-flash` on `api.nan.builders` the prompt must also contain the word JSON, otherwise `400`.'); + // "accepted", never a strictness promise for that host. + expect(description).not.toMatch(/enterprise base URL[^.]*(guarantee|constrained)/); const works = /`json_schema` works on (.+?)\.(?:\s|$)/.exec(description)!; expect(works[1]).not.toContain('deepseek-v4-flash'); }); @@ -720,13 +724,15 @@ describe('documented 400s: tool names and structured output', () => { return page.slice(start, page.indexOf('/>', start)); }; const en = card(''); - expect(en).toContain('json_object is supported (the prompt must contain the word JSON, otherwise it is rejected with a 400), json_schema is not and is rejected with a 400'); + expect(en).toContain('Structured output depends on the host: on api.helmcode.com, response_format json_schema and json_object are accepted; on api.nan.builders, json_object is supported (the prompt must contain the word JSON, otherwise it is rejected with a 400) and json_schema is rejected with a 400.'); expect(en).toContain('use qwen3.6 or gemma4'); - expect(en).toContain('json_schema not supported (400)'); + expect(en).toContain('json_schema: 400 on api.nan.builders, accepted on api.helmcode.com'); + expect(en).not.toMatch(/not supported \(400\)|json_schema is not and/); const es = card('-es'); - expect(es).toContain('json_object es compatible (el prompt debe contener la palabra JSON; si no, se rechaza con un 400), json_schema no y se rechaza con un 400'); + expect(es).toContain('La salida estructurada depende del host: en api.helmcode.com se aceptan response_format json_schema y json_object; en api.nan.builders, json_object es compatible (el prompt debe contener la palabra JSON; si no, se rechaza con un 400) y json_schema se rechaza con un 400.'); expect(es).toContain('usa qwen3.6 o gemma4'); - expect(es).toContain('json_schema no soportado (400)'); + expect(es).toContain('json_schema: 400 en api.nan.builders, aceptado en api.helmcode.com'); + expect(es).not.toMatch(/no soportado \(400\)|json_schema no y/); }); }); @@ -897,7 +903,8 @@ describe('documented 400s: tool parameters and context overflow', () => { it('explains the deepseek-v4-flash 16384 floor on max_tokens, in the spec and on both cards', () => { const maxTokens = (spec.paths as any)['/chat/completions'].post.requestBody.content['application/json'] .schema.properties.max_tokens.description; - expect(maxTokens).toContain('On `deepseek-v4-flash` a smaller value is raised to 16384'); + expect(maxTokens).toContain('On `deepseek-v4-flash`, while reasoning is on, a smaller value is raised to 16384'); + expect(maxTokens).toContain('with reasoning off (`reasoning_effort: "none"` or an alias) the value is used as sent'); expect(maxTokens).toContain('1,048,576-token window'); // Not promised as the exact text on deepseek-v4-flash: measured generic there. expect(maxTokens).not.toMatch(/deepseek-v4-flash[^.]*Context length exceeded/); @@ -911,9 +918,9 @@ describe('documented 400s: tool parameters and context overflow', () => { const start = page.indexOf('id="deepseek-v4-flash"'); return page.slice(start, page.indexOf('/>', start)); }; - expect(card('')).toContain('A max_tokens below 16384 is raised to 16384 so the reasoning fits'); + expect(card('')).toContain('While reasoning is on, a max_tokens below 16384 is raised to 16384 so the reasoning fits'); expect(card('')).toContain('rejected with a 400 even with a small max_tokens (usually Context length exceeded, sometimes a generic Invalid request)'); - expect(card('-es')).toContain('Un max_tokens por debajo de 16384 se sube a 16384'); + expect(card('-es')).toContain('Con el razonamiento activado, un max_tokens por debajo de 16384 se sube a 16384'); expect(card('-es')).toContain('se rechaza con un 400 aunque pidas un max_tokens pequeño (normalmente Context length exceeded, a veces un Invalid request genérico)'); }); }); @@ -933,7 +940,7 @@ describe('documented structured output: guarantees and the deepseek-v4-flash rec it('ResponseFormat states what json_object and json_schema each guarantee', () => { expect(rf).toContain('`json_object`: syntactically valid JSON only, NOT its shape. Describe the schema you want in the prompt.'); - expect(rf).toContain('On `deepseek-v4-flash` the prompt must also contain the word JSON'); + expect(rf).toContain('On `deepseek-v4-flash` on `api.nan.builders` the prompt must also contain the word JSON'); expect(rf).toContain('`json_schema` with `strict: true`: the output is constrained to the schema (fields, types and required keys).'); expect(rf).toMatch(/force it with `tool_choice: \{"type": "function", "function": \{"name": "\.\.\."\}\}`/); expect(rf).toContain('`choices[0].message.tool_calls[0].function.arguments`'); @@ -993,6 +1000,9 @@ describe('documented structured output: guarantees and the deepseek-v4-flash rec expect(section).toMatch(locale ? /devuelve argumentos que siguen el esquema/ : /returns arguments that follow the schema/); expect(section).toMatch(locale ? /donde el modelo admite decodificación estricta/ : /where the model supports strict decoding/); expect(section).not.toMatch(/guaranteed|garantizad/); + // The 400 is host-specific: api.helmcode.com accepts json_schema on this model. + expect(section).toContain(locale ? 'En `api.nan.builders`, `deepseek-v4-flash` rechaza' : 'On `api.nan.builders`, `deepseek-v4-flash` rejects'); + expect(section).toContain(locale ? 'en `api.helmcode.com` se acepta' : 'on `api.helmcode.com` it is accepted'); }); } }); diff --git a/src/tests/lib/docsReasoning.test.ts b/src/tests/lib/docsReasoning.test.ts new file mode 100644 index 00000000..bd7ed738 --- /dev/null +++ b/src/tests/lib/docsReasoning.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import spec from '../../data/openapi.json'; + +/** + * THE REASONING CONTRACT, as published. Measured live 2026-10-15 on both + * hosts and enforced by the proxy's reasoning-effort hook (nan-devops, + * scripts/litellm-reasoning-effort-hook/): one parameter turns reasoning off + * on every chat model, the other providers' switches are accepted as aliases, + * and the one model that cannot stop reasoning (GLM) says so. A customer had + * to guess before this, guessed DeepSeek's own `thinking` switch, and kept + * reasoning on; these tests keep the reference from going back to "each model + * is different, find out". + */ + +const CHAT_MODELS = [ + 'deepseek-v4-flash', + 'qwen3.8-flash', + 'mimo-v2.6-flash', + 'qwen3.6', + 'gemma4', + 'glm5.3', + 'glm5.3-flash', +]; +const LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; +const ALIASES = [ + '`thinking: {"type": "disabled"}`', + '`reasoning: {"enabled": false}`', + '`enable_thinking: false`', + '`chat_template_kwargs: {"enable_thinking": false}`', +]; + +const here = dirname(fileURLToPath(import.meta.url)); +const page = (locale: '' | '-es') => + readFileSync(resolve(here, `../../content/docs${locale}/models.mdx`), 'utf-8'); + +/** The "Controlling reasoning" section of a models page. */ +const section = (locale: '' | '-es') => { + const text = page(locale); + const heading = locale === '' ? '## Controlling reasoning.' : '## Controlar el razonamiento.'; + const start = text.indexOf(heading); + expect(start, `${heading} missing`).toBeGreaterThan(-1); + const end = text.indexOf('\n## ', start + heading.length); + return text.slice(start, end); +}; + +/** One model's row in that section's table. */ +const row = (text: string, model: string) => + text.split('\n').find((l) => l.startsWith('|') && l.split('|')[1].includes(`\`${model}\``)) ?? ''; + +/** The feature line of a model card that talks about reasoning. */ +const cardReasoning = (locale: '' | '-es', model: string) => { + const text = page(locale); + const start = text.indexOf(`name="${model}"`); + expect(start, `card ${model} missing`).toBeGreaterThan(-1); + const card = text.slice(start, text.indexOf('/>', start)); + return card.split('\n').filter((l) => /reasoning|razonamiento|Razonamiento/i.test(l) && l.trim().startsWith("'")); +}; + +const chatBody = (spec.paths as any)['/chat/completions'].post.requestBody.content['application/json'].schema.properties; +const respBody = (spec.paths as any)['/responses'].post.requestBody.content['application/json'].schema.properties; + +describe('API reference: one documented way to turn reasoning off', () => { + const desc: string = spec.info.description; + const reasoning = desc.slice(desc.indexOf('## Reasoning'), desc.indexOf('\n## ', desc.indexOf('## Reasoning') + 3)); + + it('has a Reasoning section in the overview', () => { + expect(desc).toContain('\n## Reasoning\n'); + }); + + it('names the one switch for both APIs', () => { + expect(reasoning).toContain('"reasoning_effort": "none"'); + expect(reasoning).toContain('"reasoning": {"effort": "none"}'); + // The hook normalises only catalogue models; off-catalogue models get no alias rewriting. + expect(reasoning).toMatch(/every model in the catalogue/); + for (const d of [ + reasoning, + chatBody.reasoning_effort.description, + chatBody.chat_template_kwargs.description, + respBody.reasoning.description, + ]) { + expect(d).not.toMatch(/every chat model/); + } + }); + + it('says what happens with a value outside the list', () => { + expect(reasoning).toMatch(/Any other value is passed through unchanged/); + }); + + it('the model catalog agrees: qwen3.6 and gemma4 reason by default', () => { + const catalog = desc.slice(desc.indexOf('## Model catalog'), desc.indexOf('\n## ', desc.indexOf('## Model catalog') + 3)); + expect(catalog).not.toMatch(/opt-in|opt-out/); + for (const model of ['qwen3.6', 'gemma4']) { + const line = catalog.split('\n').find((l) => l.startsWith(`| \`${model}\``)) ?? ''; + expect(line, model).toContain('reasoning (on by default; `reasoning_effort: "none"` turns it off)'); + } + }); + + it('max_tokens: the deepseek floor applies only while reasoning is on', () => { + expect(chatBody.max_tokens.description).toContain( + 'On `deepseek-v4-flash`, while reasoning is on, a smaller value is raised to 16384 so the reasoning fits, so a prompt within 16384 tokens of its 1,048,576-token window is rejected with a `400` even with a small `max_tokens`; with reasoning off (`reasoning_effort: "none"` or an alias) the value is used as sent.', + ); + }); + + it('lists the accepted aliases and says the explicit value wins', () => { + for (const alias of ALIASES) expect(reasoning).toContain(alias); + expect(reasoning).toContain('`extra_body`'); + expect(reasoning).toMatch(/value wins/); + }); + + it('says plainly that GLM cannot stop reasoning, and what none does there', () => { + expect(reasoning).toMatch(/`glm5\.3` and `glm5\.3-flash` always reason/); + expect(reasoning).toMatch(/`none` is applied as `minimal`/); + }); + + it('points at the per-model table', () => { + expect(reasoning).toContain('https://nan.builders/docs/models#controlling-reasoning'); + }); + + it('chat reasoning_effort: every level, and none turns it off', () => { + expect(chatBody.reasoning_effort.enum).toEqual(LEVELS); + expect(chatBody.reasoning_effort.description).toMatch(/`none` turns reasoning off/); + // The old reference said the parameter had no effect on deepseek-v4-flash. It does. + expect(chatBody.reasoning_effort.description).not.toMatch(/no effect/); + expect(JSON.stringify(spec)).not.toMatch(/accepted and has no effect/); + }); + + it('chat_template_kwargs is documented as an alias, not as the way', () => { + expect(chatBody.chat_template_kwargs.description).toMatch(/alias/i); + expect(chatBody.chat_template_kwargs.description).toContain('reasoning_effort'); + }); + + it('responses documents reasoning.effort with the same levels', () => { + expect(respBody.reasoning).toBeDefined(); + expect(respBody.reasoning.properties.effort.enum).toEqual(LEVELS); + expect(respBody.reasoning.description).toMatch(/`none` turns reasoning off/); + }); +}); + +describe('models page: the per-model table is the contract (en + es)', () => { + for (const locale of ['', '-es'] as const) { + const text = section(locale); + + it(`${locale || 'en'}: has a row for every chat model`, () => { + for (const model of CHAT_MODELS) expect(row(text, model), model).not.toBe(''); + }); + + it(`${locale || 'en'}: names both switches and every alias`, () => { + expect(text).toContain('`reasoning_effort`'); + expect(text).toContain('`reasoning: {"effort": ...}`'); + for (const alias of ALIASES) expect(text).toContain(alias); + for (const level of LEVELS) expect(text).toContain(`\`${level}\``); + }); + + it(`${locale || 'en'}: none is off everywhere except GLM, which says it cannot`, () => { + const off = locale === '' ? /\| Off/ : /\| Apagado/; + for (const model of ['deepseek-v4-flash', 'qwen3.8-flash', 'mimo-v2.6-flash', 'qwen3.6']) { + expect(row(text, model), model).toMatch(off); + } + const glm = row(text, 'glm5.3'); + expect(glm).toContain('`minimal`'); + expect(glm).toMatch(locale === '' ? /always reasons/ : /siempre razona/); + }); + + it(`${locale || 'en'}: documents the remaps and the Responses limit on vLLM`, () => { + expect(row(text, 'mimo-v2.6-flash')).toMatch(/`minimal`.*`low`.*`xhigh`.*`max`/); + expect(row(text, 'qwen3.6')).toContain('32,768'.replace(',', locale === '' ? ',' : '.')); + expect(row(text, 'qwen3.6')).toMatch(/Responses/); + }); + + it(`${locale || 'en'}: no row claims the parameter does nothing`, () => { + expect(text).not.toMatch(/no effect|sin efecto|not adjustable|no ajustable/); + }); + + it(`${locale || 'en'}: shows how to turn it off`, () => { + expect(text).toContain('"reasoning_effort": "none"'); + expect(text).toContain('reasoning={"effort": "none"}'); + }); + + it(`${locale || 'en'}: SDK examples are copy-pasteable in Python and Node, on both hosts`, () => { + // Each example builds its own client. + expect(text).toContain('client = OpenAI('); + expect(text).toContain('const client = new OpenAI({'); + expect(text).toContain('base_url="https://api.nan.builders/v1"'); + expect(text).toContain("baseURL: 'https://api.nan.builders/v1'"); + expect(text).toContain('https://api.helmcode.com/v1'); + // Older Python SDKs do not know the parameter. + expect(text).toContain('extra_body={"reasoning_effort": "none"}'); + // Node, both APIs. + expect(text).toContain("reasoning_effort: 'none'"); + expect(text).toContain("reasoning: { effort: 'none' }"); + // TypeScript: typed unions may lack a value. + expect(text).toContain('// @ts-expect-error'); + }); + + it(`${locale || 'en'}: names the APIs, not "both", and says what an unlisted value does`, () => { + expect(text).not.toMatch(/both APIs|las dos APIs/); + expect(text).toMatch(locale === '' ? /on Chat Completions and on Responses/ : /en Chat Completions y en Responses/); + expect(text).toMatch(locale === '' ? /Any other value is passed through unchanged/ : /Cualquier otro valor se env[ií]a tal cual/); + }); + + it(`${locale || 'en'}: the table header does not render as a shouted NONE`, () => { + expect(text).toContain(locale === '' ? '| Model | Off switch (`none`) |' : '| Modelo | Para apagar (`none`) |'); + }); + + it(`${locale || 'en'}: names no upstream provider`, () => { + expect(text).not.toMatch(/OpenRouter|Novita|DashScope|Z\.AI|vLLM/); + }); + } +}); + +describe('model cards agree with the table (en + es)', () => { + for (const locale of ['', '-es'] as const) { + it(`${locale || 'en'}: every chat card says how reasoning is controlled`, () => { + for (const model of CHAT_MODELS) { + const lines = cardReasoning(locale, model); + expect(lines.length, model).toBeGreaterThan(0); + const joined = lines.join(' '); + expect(joined, model).toContain('reasoning_effort'); + if (model.startsWith('glm')) expect(joined, model).toMatch(/always reasons|siempre razona/); + else expect(joined, model).toContain('none'); + } + }); + + it(`${locale || 'en'}: card bullets use , never literal backticks, and link to the section`, () => { + const anchor = locale === '' ? '#controlling-reasoning' : '#controlar-el-razonamiento'; + for (const model of CHAT_MODELS) { + const joined = cardReasoning(locale, model).join(' '); + expect(joined, model).not.toContain('`'); + expect(joined, model).toContain(``); + } + }); + + it(`${locale || 'en'}: qwen3.6 and gemma4 cards agree with the table on minimal`, () => { + for (const model of ['qwen3.6', 'gemma4']) { + expect(cardReasoning(locale, model).join(' '), model).toContain('none'); + expect(cardReasoning(locale, model).join(' '), model).toContain('minimal'); + } + }); + } +}); + +describe('deepseek-v4-flash card: the max_tokens floor only applies while reasoning is on (en + es)', () => { + const card = (locale: '' | '-es') => { + const text = page(locale); + const start = text.indexOf('name="deepseek-v4-flash"'); + return text.slice(start, text.indexOf('/>', start)); + }; + + it('en', () => { + expect(card('')).toContain( + 'While reasoning is on, a max_tokens below 16384 is raised to 16384 so the reasoning fits, so a prompt within 16384 tokens of the 1,048,576-token window is rejected with a 400 even with a small max_tokens (usually Context length exceeded, sometimes a generic Invalid request). With reasoning off (reasoning_effort "none" or any of its aliases) your max_tokens is used as sent.', + ); + }); + + it('es', () => { + expect(card('-es')).toContain('Con el razonamiento activado, un max_tokens por debajo de 16384 se sube a 16384'); + expect(card('-es')).toContain('Con el razonamiento apagado (reasoning_effort "none" o cualquiera de sus alias) tu max_tokens se usa tal cual.'); + }); +}); + +describe('es: no voseo', () => { + it('uses "Baja", not "Bajá"', () => { + expect(page('-es')).not.toContain('Bajá'); + }); +});