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/v1when 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-gatewayinstead, 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 anid; 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
protocolmeanschat_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 inbase_url(https://llm.internal.example/v1) or inendpoint_prefix(/v1with a bare host) — never in both. protocolis provider-level, and anymodels[]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
400on 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-levelreasoning_effortstring, 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 abudget_tokensnumber.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-gatewayover an inlineapi_key: credentials live in<CETAS_HOME>/.cetas/credentials/, out of the file people paste into tickets. - A gateway that rejects
prompt_cache_keyis 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_windowsilently degrades compaction rather than erroring.
Verify it
/login my-gateway
/modelThen 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.