Skip to content

Custom & Self-Hosted Models

Point Cetas at a private endpoint, gateway, or in-house deployment.

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

{
  "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
{
  "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
{
  "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

/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 for the built-in ids that take precedence over a compatible key.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close