> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nuwacom.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Completion API Overview

> Use any OpenAI-compatible SDK against a nuwacom space

The Completion API is an OpenAI Chat Completions–compatible endpoint scoped to a single space. Point an OpenAI SDK at it and it works unmodified: the space is a path segment, so nothing nuwacom-specific has to be smuggled into the request body.

Each request runs against a server-side agent with access to that space's knowledge base. Fields the endpoint does not recognise are rejected rather than ignored, so a request that is accepted is a request that was honoured in full. Retrieval is off unless you ask for it: to ground answers in the knowledge base, send `knowledge` — see [Grounding the answer](#grounding-the-answer). Web search is off too unless `knowledge` includes `web` — see [Web search](#web-search).

## Before you start

* **API key**: every request needs a Bearer token. Admins create API keys in the workspace settings under the **API Keys** section.
* **Key permissions**: the key needs **Workspaces: Read** on the space in the URL, otherwise the request is a `403`. For answers grounded in the knowledge base it also needs **KB assets: Read** (uploaded files) and **Contents: Read** (authored documents): without them retrieval finds nothing, and the answer comes back without grounding rather than as an error. A key never reaches further than the role of the admin who created it.
* **Keep the key server-side**: the endpoint accepts requests from browsers, so a key shipped in client-side code can be used by anyone who reads it.
* **Model ids**: ask `GET .../openai/models` for the ids this space accepts. They are nuwacom catalog keys (for example `claude-sonnet-5-5` or `openai-gpt-6.1-sol`), not OpenAI model names.

## Base URL

```
https://{customer-tenant}.nuwacom.ai/api/v1/spaces/{spaceId}/openai
```

Replace `{customer-tenant}` with your workspace's tenant name and `{spaceId}` with the space the request should run against. That URL is what an SDK takes as its `base_url`; the SDK appends `/chat/completions` and `/models` itself.

## Endpoints

| Method | Endpoint | Description |
| - | - | - |
| `POST` | `/api/v1/spaces/{spaceId}/openai/chat/completions` | [OpenAI Chat Completion](https://docs.nuwacom.ai/api-reference/completion-api/openai-chat-completion.md) |
| `GET` | `/api/v1/spaces/{spaceId}/openai/models` | [List models](https://docs.nuwacom.ai/api-reference/completion-api/list-models.md) |
| `GET` | `/api/v1/spaces/{spaceId}/openai/models/{model}` | [Retrieve model](https://docs.nuwacom.ai/api-reference/completion-api/retrieve-model.md) |

`GET /models` returns the public model catalog narrowed to what this space's administrators have enabled in Settings, and `GET /models/{model}` answers one entry of that same list — which is also the set `POST /chat/completions` gates on, so the three cannot disagree.

### Model capabilities

Each model entry carries a `capabilities` object, a nuwacom extension the OpenAI SDKs keep as an extra field (`model.model_extra["capabilities"]` in Python). It is read off the same rules `POST /chat/completions` enforces, so what it allows is what a request may send:

```json theme={null}
{
  "id": "openai-gpt-6.1-sol",
  "object": "model",
  "created": 1791504000,
  "owned_by": "nuwacom",
  "capabilities": {
    "response_formats": ["text", "json_object", "json_schema"],
    "reasoning_efforts": ["low", "medium", "high", "xhigh", "max"],
    "unsupported_parameters": ["frequency_penalty", "presence_penalty", "seed", "stop", "top_p"],
    "fixed_temperature": 1.0,
    "max_temperature": null
  }
}
```

| Field | Meaning |
| - | - |
| `response_formats` | The `response_format` types the model can be held to. `text` is always there. |
| `reasoning_efforts` | The `reasoning_effort` values it takes. `null` accepts every value; an empty list accepts none. |
| `unsupported_parameters` | Parameters refused with a `400` whatever their value: sampling parameters, and `knowledge` on a model that cannot call tools. |
| `fixed_temperature` | The only `temperature` it takes, where it takes exactly one. |
| `max_temperature` | The highest `temperature` it takes, where that is below `2`. |

This API serves Chat Completions and Models only. Other OpenAI endpoints — the Responses API, Embeddings, Files — answer with an OpenAI-shaped `404` naming what is served, rather than a bare not-found.

## Quickstart

> [tab] OpenAI Python SDK
>
> ```python theme={null}
> from openai import OpenAI
>
> client = OpenAI(
>     api_key="YOUR_NUWACOM_API_KEY",
>     base_url="https://{customer-tenant}.nuwacom.ai/api/v1/spaces/{spaceId}/openai",
> )
>
> for entry in client.models.list().data:
>     print(entry.id)
>
> completion = client.chat.completions.create(
>     model="claude-sonnet-5-5",
>     messages=[{"role": "user", "content": "Say hello in one short sentence."}],
> )
> print(completion.choices[0].message.content)
> ```
>
> Streaming is the same call with `stream=True`; the chunk that carries usage has no choices, so skip it:
>
> ```python theme={null}
> stream = client.chat.completions.create(
>     model="claude-sonnet-5-5",
>     messages=[{"role": "user", "content": "Count to five, digits only."}],
>     stream=True,
> )
> for chunk in stream:
>     if not chunk.choices:
>         continue
>     print(chunk.choices[0].delta.content or "", end="", flush=True)
> ```

> [tab] Vercel AI SDK
>
> ```typescript theme={null}
> import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
> import { generateText, streamText } from 'ai';
>
> const nuwa = createOpenAICompatible({
>   name: 'nuwacom',
>   apiKey: process.env.NUWACOM_API_KEY!,
>   baseURL: 'https://{customer-tenant}.nuwacom.ai/api/v1/spaces/{spaceId}/openai',
> });
>
> const { text } = await generateText({
>   model: nuwa.chatModel('openai-gpt-6.1-sol'),
>   prompt: 'Say hello in one short sentence.',
> });
> console.log(text);
>
> const stream = streamText({
>   model: nuwa.chatModel('openai-gpt-6.1-sol'),
>   prompt: 'Count to five, digits only.',
> });
> for await (const delta of stream.textStream) {
>   process.stdout.write(delta);
> }
> ```
>
> > [!NOTE]
> >
> > Use `@ai-sdk/openai-compatible`, or `openai.chat(id)` from the OpenAI provider. The default `openai(id)` targets OpenAI's Responses API, which this endpoint does not serve.
>
> For `generateObject`, pass `supportsStructuredOutputs: true` to `createOpenAICompatible` so the SDK sends `response_format` rather than an output tool.

> [tab] Pydantic AI
>
> ```python theme={null}
> from pydantic_ai import Agent
> from pydantic_ai.models.openai import OpenAIChatModel
> from pydantic_ai.providers.openai import OpenAIProvider
>
> model = OpenAIChatModel(
>     "openai-gpt-6.1-sol",
>     provider=OpenAIProvider(
>         base_url="https://{customer-tenant}.nuwacom.ai/api/v1/spaces/{spaceId}/openai",
>         api_key="YOUR_NUWACOM_API_KEY",
>     ),
> )
> agent = Agent(model, instructions="Reply in one short sentence.")
> print(agent.run_sync("Say hello.").output)
> ```
>
> > [!NOTE]
> >
> > Do not register tools on the `Agent`: client-side function calling is refused, because the request already runs a server-side agent that executes its own tools. For structured output use `NativeOutput`, which makes Pydantic AI send `response_format` instead of an output tool.

> [tab] cURL
>
> ```bash theme={null}
> curl https://{customer-tenant}.nuwacom.ai/api/v1/spaces/{spaceId}/openai/chat/completions \
>   -H "Authorization: Bearer $NUWACOM_API_KEY" \
>   -H "Content-Type: application/json" \
>   -d '{
>     "model": "claude-sonnet-5-5",
>     "messages": [{"role": "user", "content": "Say hello in one short sentence."}]
>   }'
> ```
>
> Add `"stream": true` for server-sent events, ending with `data: [DONE]`.

> [!NOTE]
>
> Coding agents that execute their own tools on your machine — Cursor's or GitHub Copilot's agent mode, Claude Code, Codex — cannot use this endpoint: it refuses client-side tools, because the request already runs a server-side agent.

## Supported parameters

The endpoint accepts OpenAI's Chat Completions body with the following exceptions. Anything marked **Not supported** is answered with a `400` whose message says why, rather than being silently dropped.

| Parameter | Status | Notes |
| - | - | - |
| `model` | Supported | Required. Must be an id this space may name — see [List models](https://docs.nuwacom.ai/api-reference/completion-api/list-models.md). An id outside the catalog is a `400`; a catalogued id this space cannot use is a `404 model_not_found`. |
| `messages` | Supported | Required, at least one. Roles: `system`, `developer`, `user`, `assistant`. Content is a string or a list of `text` / `image_url` parts. The final message must be a `user` message: it is the prompt, so ending on an `assistant` turn (to continue it) or sending only a `system` message is a `400`. |
| `name` (on a message) | Accepted, ignored | OpenAI's participant label. Accepted so clients that set it (LangChain does) are not refused; nothing downstream distinguishes participants. |
| `stream` | Supported | `text/event-stream` of chunks, terminated by `data: [DONE]`. |
| `stream_options.include_usage` | Supported | Totals arrive on a final chunk with an empty `choices` array. Sending `stream_options` without `stream: true` is a `400`. |
| `stream_options.include_obfuscation` | Accepted, ignored | We emit no padding tokens. Refusing the flag would `400` a stream that also asked for usage, which is how OpenAI's SDK sends it. |
| `temperature` | Supported | `0`–`2`, except Claude models, which take `0`–`1`; above that is a `400`. |
| `top_p` | Supported | Greater than `0`, up to `1`. `0` is a `400`: several backends refuse it. |
| `max_tokens`, `max_completion_tokens` | Supported | `≥ 1`. Interchangeable spellings of the same cap; sending both is a `400`. |
| `stop` | Supported | A single string of any length, or a list of at most 4 sequences. |
| `seed` | Supported | Any integer. Best-effort — the backends do not all guarantee determinism. |
| `presence_penalty`, `frequency_penalty` | Supported | `-2`–`2`. |
| `n` | Supported | Must be `1` if set. This endpoint returns a single completion. |
| `reasoning_effort` | Supported | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Applies to the reasoning families; models with no reasoning to spend ignore it. Each OpenAI model takes the values OpenAI documents for it (the o-series has `low`, `medium` and `high` only), and GPT-5.6 takes `none` only. Claude Opus 4.5 and 4.6 and the Gemini 2.5 thinking models reason on a fixed budget and take no effort at all. A value the chosen model does not take is a `400` that lists the ones it does. |
| `response_format` | Supported | `text`, `json_object`, `json_schema`. `strict` defaults to `true` here where OpenAI defaults it to `false`. A model whose backend cannot receive a schema is refused up front — see [Structured output](#structured-output). |
| `user` | Supported | An end-user identifier, recorded on the request span for abuse attribution — the same use OpenAI documents. |
| `safety_identifier` | Supported (alias) | OpenAI's newer name for `user`. Send one, or the same value twice; disagreeing values are a `400`. |
| `knowledge` | Supported (nuwacom extension) | Grounds the answer. See [Grounding the answer](#grounding-the-answer). |
| `tools`, `tool_choice`, `functions`, `function_call`, `parallel_tool_calls`, `tool_calls` | Not supported (400) | The request runs a server-side agent that executes its own tools and returns a final answer, so tool calls are never handed back to the caller to execute. |
| `logit_bias`, `logprobs`, `top_logprobs` | Not supported (400) | Tokenizer-level controls of OpenAI's own models; they cannot be honoured for every id this endpoint lists. |
| `verbosity` | Not supported (400) | Same reason — refused rather than applied only when the backend happens to be OpenAI. |
| `web_search_options` | Not supported (400) | Web search is a knowledge source here: send `knowledge: [{ "type": "web" }]`. See [Web search](#web-search). |
| `audio`, `modalities` | Not supported (400) | Audio input and output are not available. Content parts must be `text` or `image_url`. |
| `store`, `metadata`, `service_tier`, `prediction`, `moderation`, `prompt_cache_key`, `prompt_cache_options`, `prompt_cache_retention` | Not supported (400) | Specific to OpenAI's own platform; the request runs against whatever backend serves the chosen model. For caching, see [Prompt caching](#prompt-caching). |
| `stream_options.continuous_usage_stats` | Not supported (400) | Mid-stream usage stats are not available; `stream_options.include_usage` reports totals on the final chunk. |
| `image_url.detail` | Not supported (400) | Nothing downstream acts on it, so it is rejected rather than silently dropped. |

Unknown fields are rejected rather than ignored, so a `200` means every field you sent was honoured.

> [!NOTE]
>
> Sampling parameters are applied where the backend serving the model takes them. The OpenAI reasoning families (o-series, GPT-5.x) accept neither `temperature`/`top_p` nor `stop`, `seed` and the penalties, so sending one to those models is a `400` that names the parameter and the model, rather than a `200` that silently ignored it; Claude refuses a `temperature` above `1` with a `400`; and `reasoning_effort` is folded onto the nearest rung of a backend's own scale (`xhigh` and `max` both reach Anthropic as `max`).

### Images

An `image_url` part takes a `data:` URI or an `http(s)` URL, and only on a `user` message. One request may carry at most 10 images, 20 MB each and 40 MB in total. Inlined `data:` URIs also count towards the [request size limit](#limits), so send larger images as URLs. The internal `storage://` scheme is refused — reach knowledge base assets through `knowledge` instead.

### Structured output

Which of `text`, `json_object` and `json_schema` a model supports depends on the backend serving it, which the model id does not reveal — `capabilities.response_formats` on its [model entry](#model-capabilities) lists them:

* Served by OpenAI — `json_schema` and `json_object`
* Served by Anthropic — `json_schema` only; there is no free-form JSON mode
* Served by Bedrock or Google — neither; these have no way to receive a schema

Rather than answer with prose where you expected parseable JSON, a model that cannot be held to the format you asked for is refused with a `400`.

### Finish reasons

`finish_reason` is `stop` for a complete answer, `length` when the output reached the token cap and is worth continuing, and `content_filter` when the provider withheld it. It is never `tool_calls`: tools run server-side, and the caller only ever receives the final answer.

### Prompt caching

Prompt caching is on by default and needs nothing in the request. When a request begins with the same tokens as a recent request to the same model, the provider can reuse that prefix instead of processing it again, which mainly shortens the wait on long prompts and long conversations.

How the prefix gets cached depends on the backend serving the model:

| Model family | What happens |
| - | - |
| Claude | nuwacom marks the instructions, the tool definitions and the conversation as cacheable on every request. A cached prefix stays available for about five minutes after it was last used. |
| GPT | The provider caches long repeated prefixes on its own. For GPT-5.6 and GPT-6 models nuwacom also sets cache breakpoints. |
| Gemini and others | The provider's own caching applies where it has one. |

To get the most out of it, keep the start of your requests stable: the `system` or `developer` message first, earlier turns unchanged and in order, and whatever changes from one request to the next — the new question, per-request data — at the end. Rewriting an earlier message changes the prefix from that point on.

`usage.prompt_tokens_details.cached_tokens` reports how many of the `prompt_tokens` were read from the cache, on a non-streamed response and on the final usage chunk of a stream sent with `stream_options.include_usage`. It is `0` when nothing was cached, including on backends that do not report cache reads.

OpenAI's `prompt_cache_key`, `prompt_cache_options` and `prompt_cache_retention` are refused with a `400`: caching is managed for you, and a key or retention setting could not be honoured on every backend.

## nuwacom extensions

`knowledge` is the only field this endpoint adds to OpenAI's body. The OpenAI SDKs have no argument for it, so it goes in `extra_body` (Python) or the provider's extra-body option.

```json theme={null}
{
  "knowledge": [
    { "type": "asset", "id": "..." },
    { "type": "content", "id": "..." },
    { "type": "folder", "id": "..." }
  ]
}
```

Five reference types: `asset` is an uploaded file in the space's knowledge base, `content` is a document authored in nuwacom, `folder` is expanded into the assets and contents it holds, `space` (no `id`) is the whole space's knowledge base, and `web` (no `id`) is web search.

### Grounding the answer

Retrieval is opt-in:

| `knowledge` | Behaviour |
| - | - |
| Omitted, or `[]` | No retrieval at all: a plain model call. This is what an unmodified OpenAI SDK gets. |
| `[{ "type": "space" }]` | The agent searches the whole space's knowledge base — semantic search over uploaded assets and authored documents. |
| `asset`, `content` and `folder` references | Retrieval is pinned to exactly the references named, and nothing else — a deterministic way to fix the documents an answer must be grounded in. |

`space` cannot be combined with `asset`, `content` or `folder` references; `web` combines with any of them. Retrieval and web search run as agent tools, so a model without tool support refuses `knowledge` with a `400` — its `unsupported_parameters` lists `knowledge`.

`knowledge` is the whole retrieval scope, whatever the space's skills say: a skill that needs the knowledge base, or one with documents attached to it, only searches what `knowledge` allows. To give such a skill its documents, send `{ "type": "space" }` or pin them.

### Web search

Web search is opt-in, like retrieval. Add `{ "type": "web" }` to `knowledge` and the agent may search the web when the question needs it; leave it out and no query leaves nuwacom. The agent decides whether a search is needed rather than searching on every request. This holds whatever the space's skills say: a skill that needs web search only searches when the request includes `web`.

```python theme={null}
completion = client.chat.completions.create(
    model="claude-sonnet-5-5",
    messages=[{"role": "user", "content": "What changed in the EU AI Act this month?"}],
    extra_body={"knowledge": [{"type": "space"}, {"type": "web"}]},
)
```

OpenAI's `web_search_options` is refused with a `400` that points here, including when a framework sends it for you (Pydantic AI's `WebSearchTool` does).

Agents have their own API — see the [Agents API](https://docs.nuwacom.ai/admin/api/agents-overview.md).

## Limits

| Limit | Value |
| - | - |
| Requests | 30 per minute per user, counted in a fixed one-minute window. It is counted separately from the same user's activity in the nuwacom app, but every key created by the same user shares it. Every response carries OpenAI's `x-ratelimit-limit-requests`, `x-ratelimit-remaining-requests` and `x-ratelimit-reset-requests` headers, so a client can pace itself; over the limit is a `429` with a `Retry-After` header in seconds. |
| Request size | 5 MB per request body. A larger body is refused by the gateway with a plain `413` before it reaches the API, so it does not carry the error envelope below. |
| Images | 10 per request, 20 MB each, 40 MB in total — see [Images](#images). |
| Duration | 300 s without a byte on the connection. A non-streamed request that runs longer is cut by the gateway with a `504`, which the OpenAI SDKs retry — and a retried request runs, and is billed, again. A streamed request sends tokens as they are produced, so it stays well inside this. |

A non-streamed request sends nothing until the whole run has finished, retrieval included. For long, grounded answers prefer `stream: true`: tokens arrive as they are produced, and the connection is not left idle at the gateway.

## Errors

Every failure is answered in OpenAI's envelope, whatever the status, so an SDK raises the exception it would against OpenAI itself:

```json theme={null}
{
  "error": {
    "message": "...",
    "type": "invalid_request_error",
    "code": "unsupported_parameter",
    "param": "tools"
  }
}
```

| Status | `type` | `code` | When |
| - | - | - | - |
| `400` | `invalid_request_error` | the validation reason | A malformed or out-of-range field. `param` is the dotted path, e.g. `messages[0].role`. |
| `400` | `invalid_request_error` | `unsupported_parameter` | A field refused by design; the message says why. |
| `400` | `invalid_request_error` | — | The backend serving the model rejected the request. |
| `401` | `invalid_request_error` | `invalid_api_key` | Missing or invalid Bearer token. |
| `402` | `insufficient_quota` | `insufficient_quota` | The caller's credit balance is exhausted, or the organization's trial or subscription has expired. |
| `403` | `permission_error` | — | The key has no access to this space. |
| `403` | `permission_error` | `trial_not_supported` | The space's organization is on a trial. The API is not available until it moves to a paid plan. |
| `404` | `not_found_error` | `model_not_found` | The model does not exist, or this space may not use it. |
| `404` | `not_found_error` | `unknown_endpoint` | An endpoint this API does not serve. |
| `429` | `rate_limit_error` | `rate_limit_exceeded` | Too many AI requests from your key (the response carries `Retry-After`), or the provider is rate limiting the model you chose. A request is always served by the model it names: it is retried across that model's own endpoints but never handed to a different model, so after a provider `429` wait and retry, or pick another model. |
| `413` | `pii_protection` | `413` | The input is larger than PII protection can process. Not worth retrying unchanged. |
| `503` | `pii_protection` | `503` | PII protection is on for this workspace and detection could not run, so the request was refused rather than sent unprotected. Retryable. |
| `500` | `api_error` | — | An internal error. |

A streamed request has already sent its `200` and headers before the first model call, so a failure mid-stream arrives as a `data: {"error": {...}}` frame — the same envelope, same vocabulary — immediately before `data: [DONE]`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.