---
title: "Custom & Self-Hosted Models"
description: "Point Cetas at a private endpoint, gateway, or in-house deployment."
---

> Documentation Index
> Fetch the complete documentation index at: https://colmugx.github.io/cetas/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom & Self-Hosted Models

Teams rarely adopt a coding agent against a public API. The usual shape is a
gateway in front of models you already run, with the key managed by
infrastructure rather than by each developer. Cetas treats that as a first-class
provider, not a workaround.

## How it works

Every `providers` key that is not already claimed by a built-in provider becomes
a **custom provider**. There is nothing to register and no plugin to build: the
key is the provider id, and the provider participates exactly like a built-in
one — `/login`, `/model`, catalog refresh, the model cache, and the editor host
all see it. The endpoint may speak any of three wires, declared by the entry's
`protocol`.

## Minimal configuration

```json
{
  "providers": {
    "my-gateway": {
      "base_url": "https://llm.internal.example/v1",
      "api_key": "sk-...",
      "models": [
        { "id": "qwen3-32b", "context_window": 131072 }
      ]
    }
  }
}
```

- `base_url` — required. Include `/v1` when the server expects it; most
  OpenAI-shaped servers bake the version into the base URL.
- `api_key` — required in practice. You can also omit it here and run
  `/login my-gateway` instead, which writes the credential to
  `<CETAS_HOME>/.cetas/credentials/` and keeps the secret out of the settings file.
- `models` — required and must not be empty. Each entry needs an `id`; ids must
  be unique.

`endpoint_prefix` exists for the case where `base_url` is a bare host
(`http://10.0.0.4:8080`) and the API lives under `/v1`. Prefer putting the prefix
in `base_url` and leaving this unset.

## Choose the protocol

A custom endpoint speaks one of three wires:

| `protocol` | Request | Wire |
| --- | --- | --- |
| `chat_completions` (default) | `POST {base_url}{endpoint_prefix}/chat/completions` | OpenAI Chat Completions |
| `responses` | `POST {base_url}{endpoint_prefix}/responses` | OpenAI Responses API |
| `messages` | `POST {base_url}{endpoint_prefix}/messages` | Anthropic Messages API |

```json
{
  "providers": {
    "claude-proxy": {
      "base_url": "https://api.anthropic.com",
      "endpoint_prefix": "/v1",
      "protocol": "messages",
      "api_key": "sk-ant-...",
      "models": [{ "id": "claude-sonnet-5", "context_window": 200000 }]
    }
  }
}
```

- Omitting `protocol` means `chat_completions`, so an entry written before
  protocols existed keeps its exact behavior.
- The path suffix is appended after `base_url` + `endpoint_prefix`. Put the
  version segment in `base_url` (`https://llm.internal.example/v1`) or in
  `endpoint_prefix` (`/v1` with a bare host) — never in both.
- `protocol` is provider-level, and any `models[]` entry may override it, so one
  endpoint serving several model families on different wires stays one provider
  id.
- An unknown value fails composition and names the accepted ones. It never falls
  back to the chat wire: a silent fallback would answer `400` on the wrong
  endpoint shape.

### Credentials on the wire

`auth` declares where the key rides:

| `auth` | Effect |
| --- | --- |
| `auto` (default) | `Authorization: Bearer` on `chat_completions`/`responses`; `x-api-key` plus `anthropic-version: 2023-06-01` on `messages` |
| `bearer` | `Authorization: Bearer` on every wire — for gateways fronting Messages with Bearer auth |
| `x_api_key` | `x-api-key` on every wire |

### Reasoning levels per wire

Effort levels mean the same thing everywhere — "how hard should this model
think" — but each wire spells it differently, and nothing is invented for an
endpoint whose scale is unknown:

- `chat_completions` — the top-level `reasoning_effort` string, sent verbatim.
- `responses` — `reasoning.effort`; temperature is dropped once a level is
  selected, because reasoning endpoints reject it.
- `messages` — `thinking: {"type": "enabled"}`. Level names are **not**
  converted into a `budget_tokens` number.
- `off` — nothing is sent on any wire, so the endpoint applies its own default.

The Responses wire also runs stateless (`store: false`) and does not replay
reasoning items: a generic endpoint declares no encrypted-reasoning contract.

### Output ceiling

`max_output_tokens` (positive integer, default `8192`) is the `max_tokens` value
on the `messages` wire, which requires the field. A turn that carries its own
ceiling wins; this setting only fills the gap.

## Declare what the model can do

The picker can only offer what you declare. Everything except `id` is optional,
and omitting a value is safer than guessing it:

| Field | On | Effect |
| --- | --- | --- |
| `context_window` | model | Capacity in tokens; without it, auto-compaction stays off for that model |
| `compact_threshold` | model | Trigger point as a fraction of the window, `> 0` and `<= 1` (default `0.88`) |
| `image_input` | model or provider | Accepts image input; provider-level value is the default for its models |
| `reasoning_effort` | model or provider | One fixed effort level — no picker |
| `reasoning_efforts` | model or provider | The levels to offer in the picker |

```json
{
  "providers": {
    "my-gateway": {
      "base_url": "https://llm.internal.example/v1",
      "models": [
        {
          "id": "qwen3-235b",
          "context_window": 262144,
          "compact_threshold": 0.85,
          "reasoning_efforts": ["low", "medium", "high"],
          "image_input": false
        }
      ]
    }
  }
}
```

## Secure it

- Prefer `/login my-gateway` over an inline `api_key`: credentials live in
  `<CETAS_HOME>/.cetas/credentials/`, out of the file people paste into tickets.
- A gateway that rejects `prompt_cache_key` is handled: Cetas learns the
  rejection per process and stops sending it, instead of failing the turn.
- Nothing about a custom provider is refreshed from a public model registry.
  Its catalog is exactly what you declared — which is also why a wrong
  `context_window` silently degrades compaction rather than erroring.

## Verify it

```text
/login my-gateway
/model
```

Then send one message. If `/model` is empty, the provider either failed to
compose (check the id is valid and not a built-in) or the `models` array was
rejected — a malformed section fails composition loudly rather than being
repaired.

The same configuration is picked up by the editor and the runner: nothing needs
to be repeated per surface. See [Providers](/cetas/en/configuration/providers/) for the
built-in ids that take precedence over a compatible key.

Source: https://colmugx.github.io/cetas/en/configuration/custom-models/index.mdx
