Admin REST API¶
Continuum Router mounts its administrative API under /admin when the binary is built with the admin feature. The default feature set includes it.
The Admin API is intended for a trusted management network. It fails closed: when admin.auth is absent, every Admin request returns 401 Unauthorized. An admin.auth block without method defaults to api_key and requires a key with the admin scope. To run an intentionally unauthenticated development instance, set admin.auth.method: none explicitly. Never use that opt-in on a shared network.
Authentication¶
Bearer token¶
bearer_token must be at least 16 characters. allowed_ips is optional and accepts individual IPv4/IPv6 addresses or CIDR ranges.
HTTP Basic¶
IP allowlist¶
allowed_ips can also be combined with method: bearer or method: basic. IP checks are bypassed for Unix-socket connections because they have no peer IP address.
API key¶
# Authorization: Bearer and X-API-Key are both accepted
curl -H "Authorization: Bearer $ADMIN_API_KEY" http://127.0.0.1:8080/admin/health
curl -H "X-API-Key: $ADMIN_API_KEY" http://127.0.0.1:8080/admin/health
method: api_key validates the presented key against the same API key store used by ordinary /v1 authentication and Admin key CRUD (/admin/api-keys). The key must be enabled, unexpired, and carry the required_scope (default admin). Because the store is shared, disabling, rotating, expiring, or deleting a key stops it from authenticating against the Admin API immediately, with no restart. Missing or invalid keys return 401; a valid key without the required scope, or a request from a non-whitelisted IP, returns 403. allowed_ips can be combined with method: api_key for layered security.
Authentication configuration is captured while the Admin router is built. Restart the router after changing admin.auth itself (for example switching method or required_scope); individual key changes take effect immediately through the shared store.
admin.auth is startup-owned. Admin configuration section writes and full-document imports, applies, or rollbacks refuse any candidate that changes it. Edit the configuration file and restart instead. An exported configuration never contains admin.auth, so importing an otherwise unchanged export preserves the live startup value.
Audit logging¶
admin:
audit:
enabled: true
log_level: info
include_headers: false
include_body: false
excluded_headers:
- authorization
- x-api-key
- cookie
Audit logging is enabled by default. Each Admin request that passes Admin authentication produces one admin_audit line, a JSON object with the client IP, masked credential, method, path, status, and duration.
Per-key rate-limit changes made through POST /admin/api-keys and PUT /admin/api-keys/{id} are recorded as an additional api_key_rate_limit_change line at the same admin_audit target and configured log_level, whether or not include_body is set. It carries key_id and the before and after values of rate_limit, rate_limit_burst, and rate_limit_tokens_per_minute, and never the key material. A create records only when it sets a limit, and an update records only when one of those values changes.
include_headers: true adds the request headers, minus every name on excluded_headers. Credential headers (authorization, x-api-key, api-key, x-goog-api-key, cookie, set-cookie, proxy-authorization, x-auth-token, x-access-token) are always left out, even when excluded_headers omits them or is empty.
include_body: true adds the JSON body of POST, PUT, PATCH, and DELETE requests, up to 16 KiB, as body, after masking secrets by key name (api_key, token, password, client_secret, and the other keys the configuration export masks). Two request fields that carry a secret under an ordinary name are masked as well: key in POST /admin/api-keys (the new key value) and content in POST /admin/config/import and /admin/config/validate (the whole configuration document as text). The body is read before the handler runs and handed to it unchanged, so the route's own body limit still applies. A body that is not recorded is noted instead:
body_omitted: "not_json": theContent-Typeis notapplication/json; the body is not read.body_omitted: "too_large": the body passed 16 KiB;body_bytes_readgives the bytes read before capture stopped.body_omitted: "invalid_json": the body is empty or is not a JSON object or array.body_truncated: true: the masked form was longer than 16 KiB, sobodyis that form cut to 16 KiB and stored as a string.
Masking works by key name only, so a secret placed under an ordinary key (for example inside a URL) is logged as sent. Avoid request-body logging where requests may contain secrets or user data under such keys.
Endpoint inventory¶
All paths below are relative to the router origin.
Capabilities and health¶
| Method | Path | Purpose |
|---|---|---|
GET | /admin/capabilities | Compile-time capabilities and runtime-enabled subsystems |
GET | /admin/health | Router service and backend health summary |
GET | /admin/models | Aggregated model catalog under Admin authentication |
POST | /admin/models/refresh | Refresh the aggregated model catalog |
POST | /admin/routing/explain | Read-only routing-decision explanation for a request shape (dry-run) |
Each GET /admin/models entry carries id, backends, available, status, internal, enabled, aliases, metadata, and artifacts. metadata is the first serving backend's metadata entry, as on GET /v1/models/{model}. artifacts is the per-backend artifact identity: one element per serving backend, in backends order, whose resolution declares an artifact or a draft_model, and an empty array when none does. It is the only catalog surface that shows two backends serving one model id from different weights side by side:
"artifacts": [
{
"backend": "qwen-fp8",
"artifact": {"digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "revision": "4c2e9a1"},
"draft_model": {"name": "qwen3.8-0.6b"}
},
{
"backend": "qwen-int4",
"artifact": {"digest": "sha256:0b0b000000000000000000000000000000000000000000000000000000000002"},
"draft_model": {"name": "qwen3.8-0.6b"}
}
]
artifact or draft_model is null for a backend that declares only the other. The resolution is the one usage records are stamped with: a backend's own model_configs key wins over the metadata layer, one key at a time, and the built-in OpenAI catalog is never consulted. See Model artifact identity.
Routing decision explanation¶
POST /admin/routing/explain reports, for one model and optionally one API key, which backends a chat request would be routed among and why each other candidate is excluded. It walks the candidates the live path finds for the model, applies the live path's predicates in the live path's order, and previews the configured strategy's ordering over the survivors.
It is a dry run. Every check is a read-only peek:
- The selection-time circuit check and the credential dispatch gate.
- The health snapshot and the per-backend rate-limit peek.
- The strategy preview, which loads the round-robin counter instead of advancing it, builds the hash ring locally instead of installing it in the cache, and reads scorer state without calling
prepare().
The endpoint consumes no rate-limit token, moves no circuit, creates no circuit entry, changes no health or usage counter, and records no metric. Two identical calls against the same state return byte-identical bodies. The response never carries a backend URL, a credential, or a key value.
Request body¶
| Field | Required | Meaning |
|---|---|---|
model | Yes | The model the request names, at most 256 bytes |
api_key_id | No | The id of an API key whose allowed_models and allowed_backends apply. A local key wins; otherwise the id resolves through the applied Continuum Hub policy envelope |
request | No | A request body used only as input: the prefix key for PrefixAwareHash, the token estimate for LeastCost, and classifier-shaped fields. It is never forwarded |
Response fields¶
| Field | Meaning |
|---|---|
requested_model | The model from the request |
resolved_model | The identity after backends[].model_configs alias resolution |
virtual_model | The smart-routing virtual model name while smart routing is enabled, otherwise null |
strategy | The configured selection_strategy |
selected | The predicted next pick as { "backend": ..., "model": ... }, or null when no candidate is eligible |
deterministic | false for the sampling strategies (Random, WeightedRoundRobin), whose live pick consumes randomness; true otherwise |
eligible | Backends the strategy would choose among, in candidate order |
candidates[] | One row per candidate, described below |
excluded_models | Models the smart router would leave out of its own pool, each as { "model": ..., "reason": ... }. Empty unless smart routing is enabled |
policy | Present when api_key_id is given, described below |
Each candidates[] row carries backend and status (eligible or excluded). An excluded row also carries reason, plus retry_after_ms for rate_limited. An internal: true backend also carries internal: true. An eligible row also carries:
rank: the 1-based position in the reported strategy ordering.latency_ms: the router-observed average response time, ornullbefore the first sample.score: the combined scorer score. Absent while no scorer is registered, and a scorer that only scores afterprepare()reports its neutral value because a dry run never prepares.
policy carries api_key_id, source (local or hub), key_active, model_permitted, allowed_models, allowed_models_restricted, allowed_backends_restricted, and, for a hub key, tier.
excluded_models reasons:
not_chat_capable: the model advertisesdecisionswithout text or chat output.unprofiled_model: nomodel_profilesentry matches andsmart_routing.unprofiled_modelsisexclude.
Exclusion reasons¶
Checked in this order:
| Reason | Meaning |
|---|---|
api_key_inactive | The named key is disabled, expired, or revoked |
model_not_permitted_for_key | The key's allowed_models does not list the requested model |
model_not_allowed_by_tier | The applied hub policy tier's model_allowlist lists neither the requested nor the resolved model |
backend_disabled | The backend has enabled: false |
internal_backend | The backend has internal: true and never receives user traffic |
backend_not_permitted_for_key | The key's allowed_backends does not list the backend |
circuit_open | The backend's circuit is open inside its cooldown, or half-open at its probe ceiling |
capability_mismatch | The model's metadata advertises decisions without text or chat output, so it is never a chat target |
credential_unavailable | A Hub-managed backend whose credential binding is missing or not yet restored |
not_in_backend_pool | The catalog lists the backend but the live pool does not hold it, for example during a reload |
unhealthy | The health checker marks the backend unhealthy |
rate_limited | The backend's rate_limiting.limits.per_backend bucket cannot cover one more dispatch |
Errors¶
An unknown model, or a hide_variant_models variant, answers 404 with code model_not_found. An api_key_id that neither the local key store nor the applied hub policy knows answers 404 with code api_key_not_found, and an empty or overlong model answers 400.
Not covered¶
- Capability checks that depend on request fields beyond the model metadata, for example a tools or vision request against a backend that lacks the capability.
- Rate and budget enforcement for the matched key beyond the tier model allowlist.
- Smart-routing classification of the supplied
requestbody.
X-Continuum-Route response header¶
Set server.explain_response_header: true (default false, requires a restart) to add x-continuum-route: <backend> to ordinary API responses. The header names the last backend the shared selection seam chose, so retries and fallbacks report the attempt that served the response; a request that never reaches backend selection carries no header.
It is off by default because a backend name is a deployment detail, and on a fleet of private engines it can hint at capacity and topology. The CORS layer lists the header among the exposed response headers, so a browser client can read it while the switch is on.
Backend management¶
| Method | Path | Purpose |
|---|---|---|
GET | /admin/backends | List backends and health information |
POST | /admin/backends | Create a backend |
GET | /admin/backends/{name} | Read one backend |
PUT | /admin/backends/{name} | Replace one backend |
DELETE | /admin/backends/{name} | Delete one backend |
PUT | /admin/backends/{name}/weight | Update backend weight |
PUT | /admin/backends/{name}/models | Update advertised models |
POST | /admin/backends/probe | Validate and discover a transient backend candidate without registration |
POST | /admin/backends/{name}/models/discover | Discover one backend's live model catalog without changing its selected models |
POST | /admin/backends/{name}/check | Run an on-demand connectivity check without mutating health state |
GET | /admin/backends/{name}/engine-stats | Read the latest engine-native statistics snapshot for one backend |
Backend mutations publish a new configuration snapshot through the configuration-update channel. Whether every field takes effect immediately depends on the subsystem; check /admin/config/hot-reload-status and restart for sections marked requires_restart.
Admin-authored backend destinations use a stricter egress policy than trusted startup files. Backend create, replace, transient probe, configuration section write, full-document validate/import/apply, and rollback reject a new or changed backend URL, engine_stats.metrics_url, or outbound OAuth override (device_code_endpoint, token_poll_endpoint, or token_endpoint) that is loopback, private, link-local, otherwise non-public at validation time, or a unix: URL. This applies even when global engine_stats.allow_external_metrics_url permits a different public metrics host. Hostnames are resolved off the async worker threads and every address returned at validation time must be public. This is validation-time filtering, not DNS address pinning; deployments that require protection across resolution and connection should enforce egress at the network layer too. Health-check overrides must be same-origin relative paths, and accept_status and warmup_status are each limited to 16 HTTP status codes.
Static configuration files remain the trusted path for local engines and Unix sockets. A backend loaded from such a file may keep its private destination, and Admin writes may change unrelated fields such as weight or model lists while the backend URL and health-check destination stay unchanged.
Engine statistics (GET /admin/backends/{name}/engine-stats)¶
GET /admin/backends/{name}/engine-stats returns the named backend and its most recent successful engine-native statistics snapshot.
{
"backend": "vllm-01",
"engine_stats": {
"running_requests": 3,
"waiting_requests": 1,
"kv_cache_usage": 0.42,
"generation_throughput_tps": 137.9,
"loaded_models": [],
"source": "vllm_metrics",
"staleness_seconds": 2.4
}
}
The engine_stats object always includes source (the scrape that produced it, such as vllm_metrics, sglang_loads, llamacpp_slots, or ollama_ps), staleness_seconds (the snapshot age, computed when the request is served), and loaded_models ([] when the engine lists none). Other engine-specific fields (kv_tokens_used, prefix_cache_hit_rate, context_length, engine_version, and others) are omitted when unavailable instead of being reported as zero. GET /admin/stats/backends embeds the same object for each backend that has a snapshot.
The endpoint returns 404 Not Found with an empty body when the engine-stats store holds no snapshot for the name:
- Engine statistics are off for the backend (the global
engine_stats.enabled, or the backend's ownengine_stats.enabledoverride). - The backend type has no engine-stats adapter.
- No scrape of the backend has succeeded yet.
- No backend has that name.
A later failed scrape keeps the previous successful snapshot, so check staleness_seconds when assessing its age.
Runtime backend durability (backends_persistence_file)¶
Runtime backend durability is opt-in:
- Enabling persistence: Set the root
backends_persistence_filekey and restart the router to persist backends created viaPOST /admin/backends(including WebUI creation) in an owner-only YAML sidecar. When unset, runtime backends remain memory-only and disappear on restart. - Precedence and Hub governance: File-declared and Continuum Hub-managed backends are never written to the sidecar. A file declaration wins on a same-name collision, while ordinary file hot reload preserves every unshadowed runtime backend. Hub
overlaymode preserves local runtime backends, whereas an adoptedauthoritativesnapshot may suppress them from the effective pool without deleting their sidecar records. - Hot-reload validation: Hot reload validates the configuration file combined with runtime backends and keeps the previously published configuration if the combination is invalid (for example, when a reloaded
tracing.headersname collides with a runtime backend'srequest_extensions.headers). - Status and file security:
GET /admin/backends, single-backend reads, and mutation responses reportruntime_persistence.enabledand adurabilityvalue (configured,memory, orpersistent). Sidecar credentials remain unmasked so they can be restored on startup; protect the sidecar path like the main configuration file. On Unix, the router creates and replaces the file atomically with mode0600. Source configuration files are never modified.
Live model discovery (POST /admin/backends/{name}/models/discover)¶
POST /admin/backends/{name}/models/discover is read-only. It resolves one configured backend by name, reuses the backend-specific model fetcher from normal aggregation, and returns the backend's live catalog before applying the configured backends[].models allowlist. Use it to populate an available-model picker in a management UI without altering the active routing allowlist.
curl -X POST http://127.0.0.1:8080/admin/backends/provider-openai/models/discover \
-H "Authorization: Bearer $ADMIN_TOKEN"
{
"backend": "provider-openai",
"object": "list",
"data": [
{
"id": "gpt-5.5",
"object": "model",
"created": 1760000000,
"owned_by": "openai"
}
]
}
- No side effects: Discovery never writes configuration, invalidates the aggregated model cache, or mutates backend health or circuit-breaker state. Response bodies never echo access tokens, refresh tokens, token-store paths, or raw auth headers.
- Supported backends:
- Codex OAuth: Uses the loaded OAuth strategy and account-plan filter from the normal Codex discovery path, including token refresh and
chatgpt-account-idhandling. - Native Anthropic: Queries
GET {base}/v1/modelswithx-api-keyandanthropic-version: 2023-06-01(with cursor pagination overhas_moreandafter_id). - Unsupported backends: Non-Codex OAuth backends and Bedrock return
501witherror.code: "model_discovery_unsupported".
- Codex OAuth: Uses the loaded OAuth strategy and account-plan filter from the normal Codex discovery path, including token refresh and
- Error codes: Unknown backend names return
404(backend_not_found). Upstream failures returnbackend_authentication_failed,backend_discovery_timeout,backend_discovery_network_error,backend_discovery_parse_error,backend_discovery_http_error, orbackend_discovery_response_too_large.
Transient candidate probe (POST /admin/backends/probe)¶
POST /admin/backends/probe accepts the same candidate fields as backend creation plus operations: ["health", "models"]. It requires Admin authentication on every Admin transport (TCP and Unix sockets) and is advertised by GET /admin/capabilities as transient_backend_probe_v1. The candidate is validated in memory only and never modifies active configuration, config history, backend pools, health state, circuit breakers, aggregation caches, environment files, or token stores.
The response separates health from catalog:
| Field | Values | Behavior |
|---|---|---|
health.credential_status | valid, invalid, unknown, not_required | Reports unknown when a credential was supplied to an unauthenticated health endpoint, since liveness alone does not verify the credential. Transient OAuth candidates report health.status: "unknown" with backend_probe_health_unsupported. |
catalog.source | live, curated, configured, unsupported | Model-list-capable candidates (OpenAI-compatible, Anthropic, Gemini, Ollama, vLLM, llama.cpp, MLX, LM Studio) use the same URL composition, auth headers, size caps, parsing, normalization, and model-count limits as registered discovery. When an Anthropic live query fails, catalog.source falls back to curated and records catalog.error (updating health.credential_status on 401/403), or returns configured when explicit models/model_configs are supplied without a live query. Bedrock and OAuth candidates return bounded configured entries when provided, or unsupported otherwise. |
The probe implementation in src/backend_probe/ is shared with the outbound Continuum Hub backend task executor (backend_tasks_v1). Both paths share one process-wide concurrency and rate ceiling, along with identical backend rules, response-size limits, and credential classification. The Hub path never exposes an inbound endpoint on the Router and maps results into the protocol's bounded wire format.
Capability advertisement (backend_preference_header_v1)¶
GET /admin/capabilities includes backend_preference_header_v1 in its capabilities array alongside transient_backend_probe_v1:
- Meaning: Indicates that the router honors the
x-backendrequest header wherever it selects among interchangeable backend candidates (see Choosing a Backend withx-backend). The token is present on all builds regardless of compile-time features or runtime configuration and does not list individual endpoints. - Client usage: Admin-capable clients can check for this token instead of comparing version numbers, though older routers safely ignore
x-backendif sent. Clients holding only an inference API key can detect support via thex-served-backendresponse header (see Confirming which backend answered).
Circuit-breaker state¶
| Method | Path | Purpose |
|---|---|---|
GET | /admin/circuit/all | List all circuit states |
GET | /admin/circuit/{backend}/status | Read a backend circuit state |
POST | /admin/circuit/{backend}/open | Force the circuit open |
POST | /admin/circuit/{backend}/close | Force the circuit closed |
POST | /admin/circuit/{backend}/reset | Reset the circuit |
These endpoints control the same circuit-breaker state machine that ordinary LLM proxy requests drive and consult, so they report and adjust the state used by normal proxy routing (for example, open forces a backend out of selection until it recovers).
Configuration queries¶
| Method | Path | Purpose |
|---|---|---|
GET | /admin/config | Compact configuration summary |
GET | /admin/config/full | Full current configuration with sensitive values masked |
GET | /admin/config/sections | Supported Admin Config API sections and reload capability |
GET | /admin/config/schema | JSON schema for supported Admin Config API sections |
GET | /admin/config/hot-reload-status | Runtime hot-reload status and capability lists |
GET | /admin/config/{section} | One supported section with sensitive values masked |
GET /admin/config/full returns this envelope:
{
"config": {},
"hot_reload": {
"enabled": true,
"capabilities": {}
},
"metadata": {
"retrieved_at": "2026-07-19T00:00:00Z",
"version": 1
}
}
Optional sections that are not configured are omitted from config rather than present with an explicit null value. Reading one of these sections directly is unaffected: GET /admin/config/{section} for an unconfigured but recognized section (for example metrics or fallback) still answers 200 with "config": null, not 404.
The section API accepts these names:
serverbackendshealth_checksloggingretrytimeoutsrate_limitingcircuit_breakerglobal_promptsrequest_paramsadminfallbackmodel_aliasesfilesapi_keysmetricsrouting
This is a management subset, not a list of every top-level field accepted by the main configuration schema.
Configuration validation and mutation¶
| Method | Path | Purpose |
|---|---|---|
PUT | /admin/config/{section} | Replace a supported section |
PATCH | /admin/config/{section} | Recursively merge a partial section object |
POST | /admin/config/validate | Validate YAML, JSON, or TOML without applying it |
POST | /admin/config/export | Export configuration, masked by default |
POST | /admin/config/import | Validate and optionally publish a full configuration |
GET | /admin/config/history | Read up to 50 history entries with masked snapshots |
POST | /admin/config/rollback/{version} | Publish a configuration snapshot from history |
POST | /admin/config/apply | Atomically apply a full configuration candidate, or a no-op when none is supplied |
PUT and PATCH take the section value itself as the JSON body, not a { "config": ... } wrapper:
curl -X PATCH http://127.0.0.1:8080/admin/config/rate_limiting \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"enabled":true}'
A successful section mutation includes version, requires_restart, and hot_reload_capability. The API publishes an in-memory snapshot; it does not write the source YAML/TOML file.
Validation body:
{
"content": "server:\n bind_address: 127.0.0.1:8080\nbackends: []\n",
"format": "yaml",
"sections": []
}
With an empty sections array, content must be a complete configuration. For section-scoped validation, include the named top-level sections in content and list those names in sections.
Export body:
format is yaml, json, or toml. An empty sections array exports the full configuration. All exports mask credentials and omit admin.auth. The legacy include_sensitive: true field is accepted for wire compatibility, ignored, and recorded in the audit log.
format: "toml" succeeds even when optional sections are unset: unset optional sections are omitted from the serialized configuration rather than written as null, which TOML cannot represent.
Import body:
{
"content": "server:\n bind_address: 127.0.0.1:8080\nbackends: []\n",
"format": "yaml",
"apply": false,
"dry_run": true
}
Imports are limited to 1 MiB and a maximum structural nesting depth of 32. dry_run: true validates only. apply: true publishes the full candidate through the configuration-update channel and records a history version; restart-only sections still require a restart.
Masked secrets on the export to import round trip¶
Every masked value the Admin API returns, whatever the endpoint, carries the same fixed sentinel: ***CONTINUUM-MASKED:<hint>***. The hint discloses only a length: a value longer than four characters shows a two-character prefix plus the count (sk...(21 chars)), a value of four characters or fewer shows just the count ((4 chars)), and a masked boolean or number reads ***CONTINUUM-MASKED:hidden***. This is the same masking GET /admin/config/full, GET /admin/config/{section}, GET /admin/backends, and the GET /admin/config/history snapshots use, not a format specific to export.
Export masks every credential, so the document you edit and send back normally carries these placeholders rather than secrets. Import treats each placeholder as "keep the value that is already live", splices the running secret back in, and reports every path it preserved:
{
"success": true,
"applied": true,
"version": 4,
"preserved_secret_paths": ["backends[0].api_key"],
"validation": {
"valid": true,
"warnings": [
{
"section": "backends",
"path": "backends[0].api_key",
"message": "'backends[0].api_key' carried a masked placeholder, so the value currently in effect was kept. The submitted document did not change this secret.",
"code": "MASKED_SECRET_PRESERVED"
}
]
}
}
POST /admin/config/validate emits the same MASKED_SECRET_PRESERVED warnings, so the WebUI pre-save dry run shows what an import would leave untouched before anything is committed.
The same resolution runs on every endpoint that writes a configuration document, so what the dry run promises is what the save does: POST /admin/config/import, POST /admin/config/apply with a config candidate, and PUT/PATCH /admin/config/{section}. A section write that cannot resolve a placeholder is refused with 400 Bad Request, and the body still carries success: false and the failing path in path/code.
A placeholder is only resolved when the running configuration still holds the value it was made from. Array entries are matched by id or name rather than by position, so reordering backends resolves correctly, while a rename does not. Entries that carry neither, such as the plain string lists guardrails.bypass_api_keys and rate_limiting.bypass_keys, have only their position to match on, so adding or removing one entry is refused rather than guessed: two keys from the same provider share a prefix and a length, and a shifted index would otherwise resolve to the neighbouring key. Set the real values when editing those lists. When a placeholder cannot be matched, the whole import is refused and the failing paths are named, rather than a neighbouring secret being attached to the wrong field:
| Code | Meaning |
|---|---|
MASKED_SECRET_PRESERVED | Warning. The live value at this path was kept; the submitted document did not change it. |
MASKED_SECRET_UNRESOLVED | Error. The placeholder at this path matches nothing in the running configuration, usually because an entry or section was renamed, moved between documents, or removed, or because an entry was added to or removed from a list whose entries carry no id or name. Set the real value, or re-export and edit that document. |
LEGACY_MASK_PLACEHOLDER | Error. The value has one of the placeholder shapes (***MASKED***, xy...(N chars), ${***VAR***}) emitted before the sentinel existed, typically because the document was exported before an upgrade. Those shapes are derived from the secret itself and cannot be told apart from a real credential, so they are never used to restore one. Re-export the current configuration and edit that document, or replace the flagged value with the real secret. |
MASKED_SECRET_REPORT_TRUNCATED | Reported at most once, as a warning or an error depending on which list it summarises. The per-path lists are capped at 500 entries so a document full of placeholders cannot inflate the response; the entry states how many further paths were preserved or refused without being listed. |
A value that already equals the running one is left alone, whatever it looks like, so a configuration damaged by an older build (whose live credentials are themselves stale placeholder shapes) can still be edited section by section without retyping every secret first.
Values that are not placeholders are written through unchanged, so rotating a credential still works: replace the placeholder with the new secret and import as usual. An environment-variable reference round-trips as a reference: ${OPENAI_API_KEY} exports as ***CONTINUUM-MASKED:${OPENAI_API_KEY}*** and is restored to ${OPENAI_API_KEY}, never to the resolved value.
Apply body:
{
"hot_reload": true,
"description": "optional audit note",
"config": { "...": "a full configuration object" }
}
POST /admin/config/apply only does work when you supply a full config candidate. Section edits (PUT/PATCH), import, and rollback already publish immediately, so:
- No
config(ornull): explicit no-op. Nothing is published, no history version is recorded, and the response reportshot_reload_triggered: falsewith an emptyupdated_sections. Repeated calls never create empty history versions. configpresent,hot_reload: true: the candidate is validated, diffed against the running configuration, and, if it differs, published atomically through the configuration-update channel. Exactly one history version is recorded with the sections that changed.hot_reload_triggeredistrueonly after a successful publish;updated_sectionsandrequires_restartreflect the real diff. A candidate refused here answers400 Bad Requestwith the refusal inmessage.configpresent,hot_reload: false: preview only. The response reports theupdated_sections/requires_restartdiff, but nothing is published and no history version is recorded. A refused candidate keeps200in this mode, because the request asked what would happen rather than for it to happen.
A candidate identical to the running configuration is treated as a no-op.
Configuration history and rollback¶
GET /admin/config/history returns up to 50 history entries (newest first), each paired with a masked config snapshot, and POST /admin/config/rollback/{version} re-applies a stored snapshot through the runtime configuration channel.
By default, history is kept in memory only (admin.max_history_entries, default 100, and admin.max_history_bytes, default 10485760 / 10 MiB). Set admin.history_dir to persist file-edit hot reloads across restarts:
admin:
history_dir: "/var/lib/continuum-router/config-history"
max_history_entries: 100
max_history_bytes: 10485760
- Pre-interpolation snapshots (
0600): On each file-edit hot reload, the router writes the raw configuration text before${VAR}variable interpolation intoadmin.history_dirwith file permissions mode0600, enforcingmax_history_entriesandmax_history_bytes. - Git-managed configuration file practice: Manage the router configuration file in Git and reference all secrets, admin tokens, and bypass keys via
${VAR}environment variables rather than literal values in the file. Because persisted snapshots store the pre-interpolation file text,${VAR}placeholders stay intact on disk and resolved secrets are never written toadmin.history_dir. On rollback (POST /admin/config/rollback/{version}), the router parses and interpolates the stored text against the current environment and re-applies the resulting configuration through the existing rollback path. - In-memory Admin API and WebUI edits: Changes made through the Admin API or WebUI screens (
PUT/PATCH /admin/config/{section},POST /admin/config/import,POST /admin/config/apply,POST /admin/config/rollback/{version}, and backend mutations) stay in the in-memory history only and are never written toadmin.history_dir.GET /admin/config/historymerges persisted file-reload entries and in-memory entries in descending version order. - Node-local scope: Configuration history is node-local by design and is not shared or replicated across router nodes.
API keys¶
| Method | Path | Purpose |
|---|---|---|
GET, POST | /admin/api-keys | List or create keys |
GET, PUT, DELETE | /admin/api-keys/{id} | Read, update, or delete a key |
POST | /admin/api-keys/{id}/rotate | Rotate key material |
POST | /admin/api-keys/{id}/enable | Enable a key |
POST | /admin/api-keys/{id}/disable | Disable a key |
API-key material is returned only when created or rotated. Store it immediately.
prompt_logging_consent (allowed or denied) sets what a key states about prompt capture. Create, get, list, and update return it when it is set. On update:
- An absent field leaves the value unchanged.
nullclears it, so the key followsprompt_logging.default_consent.- An unknown value answers
422 Unprocessable Entity.
A change applies to the next admitted request. A runtime key persists it. A key from the config file or a keys file does not: at restart it returns to the value in that file, so make a lasting change there. See Where a key's consent comes from.
Prompts and statistics¶
| Method | Path | Purpose |
|---|---|---|
GET | /admin/config/prompts | List prompt files |
POST | /admin/config/prompts/reload | Reload prompt files |
GET, PUT | /admin/config/prompts/{path} | Read or update one prompt path |
GET | /admin/stats | Global statistics snapshot |
GET | /admin/stats/series | Global time series |
GET | /admin/stats/models | Per-model statistics |
GET | /admin/stats/backends | Per-backend statistics |
GET | /admin/stats/api-keys | API-key statistics |
GET | /admin/stats/api-keys/{id} | One key's statistics |
GET | /admin/stats/api-keys/{id}/models | One key's model statistics |
GET | /admin/stats/api-keys/{id}/series | One key's time series |
GET | /admin/stats/users | User statistics |
GET | /admin/stats/users/{user_id} | One user's statistics |
GET | /admin/stats/users/{user_id}/models | One user's model statistics |
GET | /admin/stats/users/{user_id}/series | One user's time series |
POST | /admin/stats/reset | Reset in-memory statistics |
GET | /admin/metrics/history | Persistent metric history; 404 when persistence is unavailable |
GET | /admin/usage/export | Per-attempt usage records, raw or grouped, JSON or CSV; 404 unless both admin.stats.enabled and admin.stats.usage_export.enabled are on |
GET | /admin/usage/reconciliation | Ledger output tokens against engine generation counters, per backend and window; {"enabled": false, "backends": {}} while usage.reconciliation is absent or disabled |
GET /admin/stats reports both client-request and backend-dispatch metrics:
- Attempt counters (
attempts_total,successful_attempts,failed_attempts): Reported onoverall, everymodels[]entry, and everybackends[]entry alongsidetotal_requests,successful_requests, andfailed_requests.attempts_totalcounts each dispatch that reached a backend (retries, fallback-chain hops, pre-stream streaming hops, quality-check hops, and web-search tool-loop turns) once, whereastotal_requestscounts client requests (for example, a request retried once after a503recordstotal_requests: 1andattempts_total: 2). Failed attempts are attributed to the dialed backend, while requests that never reached a backend record their failure undernot_dispatched. Windowed queries (?window=) count attempts for requests completed within the window; snapshots missing these fields restore them as0. Per-key and per-user statistics track requests only. - Purpose breakdown (
by_purpose): Maps each purpose (client,guardrail,classifier,action_safety,quality_judgment) to{requests, successful_requests, failed_requests, prompt_tokens, completion_tokens, total_tokens}, filtered by the same window asoverall. Underclient,requestscounts client requests; under internal purposes, it counts router-initiated LLM calls. Internal calls do not incrementoverallrequest counters, latency metrics, orattempts_total, but their tokens are included inoverall,by_outcome, andmodels[](soby_purposetoken sums equaloverallwhenwindow=all). Purposes with zero requests and tokens are omitted. Windowed counts for internal purposes draw from a ring of the 25,000 most recent internal calls (compared to 100,000 rows for client requests).
Usage export¶
GET /admin/usage/export returns per-attempt usage records, attributing output tokens to each model that executed (including tokens generated before an interruption):
- Prerequisites and lifecycle: Requires both
admin.stats.enabledandadmin.stats.usage_export.enabled; if either is off, the endpoint returns404(usage_export_disabled). Disablingadmin.stats.enabledalone pauses recording and hides existing records without draining them; disablingusage_export.enableddrops the buffer. Records are stored per router in a bounded ring (usage_export.retention, default7d;usage_export.max_records, default1,000,000, oldest evicted first) and included in stats snapshots whenadmin.stats.persistenceis enabled. On a multi-router fleet, aggregate exports across instances or query Continuum Hub. - Record accounting: One record is emitted per backend attempt that reached a backend (including retries, fallback hops, and web-search turns) plus one per local response-cache replay. Requests that never reached a backend produce no record.
- Record schema (in field order):
completed_at_ms,request_id,attempt_seq,parent_attempt,key_id(hashed localapi_key_idoranonymous, never the raw key),user_id,team_id(alwaysnull),model,requested_model(set only oncontrol-planebuilds when a Hub rule rewrote the model),backend,backend_id(resolved by backend name at stream end;nullif renamed mid-stream),purpose,outcome,token_source,estimated,stream,http_status,input_tokens,cached_input_tokens,output_tokens,reasoning_tokens,latency_ms, andcache_hit_type.- Cache replay records: Report
backend: "local-cache",output_tokens: 0, all prompt tokens undercached_input_tokens,token_source: "none", andcache_hit_typeset toexact,prefix, orsemantic. - Model name truncation:
modelandrequested_modelare truncated to 256 bytes on a UTF-8 character boundary.
- Cache replay records: Report
| Parameter | Meaning |
|---|---|
from, to | Range [from, to) as Unix milliseconds or RFC 3339. to defaults to now and from to to minus the retention. A malformed bound or from >= to is 400 invalid_range. |
group_by | Comma list over period:1h, period:1d, model, backend_id, outcome, purpose, key_id, at most one period: token and each key once. Absent or empty returns raw records. Anything else, including a trailing comma, is 400 invalid_group_by. |
format | json (default) or csv; anything else is 400 invalid_format. |
outcome, purpose, model, backend_id | Filters, each a comma list matched exactly: AND across parameters, OR within one. |
limit | Raw mode only: 1 to 100,000, default 10,000. Out of range, not a number, or given with group_by is 400 invalid_limit. |
cursor | Raw mode only: the next of a previous page. A value this export did not issue, or one given with group_by, is 400 invalid_cursor. |
share_of | JSON only: comma list of models whose output-token share to compute. With format=csv it is 400 share_requires_json. |
Unparseable query strings return 400 invalid_query. All error responses use {"error":{"code":"...","message":"..."}}.
- Raw mode and pagination: Sorts records by
(completed_at_ms, request_id, attempt_seq)followed by an internal arrival-order sequence number, returning up tolimitrecords. The internal sequence number guarantees a total order across records with empty or sharedrequest_idvalues (such as out-of-request calls or multi-response/v1/realtimesessions) and persists across restarts when stats persistence is enabled. Thecursortoken is the URL-safe base64 encoding of{completed_at_ms}:{request_id}:{attempt_seq}:{sequence}from the last returned record. JSON responses use{"from": <rfc3339>, "to": <rfc3339>, "group_by": [], "records": [...], "next": <cursor or null>}; CSV responses pass the cursor in theX-Next-Cursorheader (omitted on the final page). - Grouped mode: Returns one row per distinct key tuple, sorted by the tuple. Each row lists the
group_bykeys in order (period_startin RFC 3339 UTC floored to the hour or UTC day; missing values arenullin JSON and empty in CSV) followed by seven metrics:requests(distinctrequest_idcount, with out-of-scope records counted individually),attempts(record count),input_tokens,cached_input_tokens,output_tokens,reasoning_tokens, andestimated_output_tokens(output tokens from records withestimated: true). JSON responses use{"from", "to", "group_by": [...], "rows": [...]}. - CSV format and formula-injection escaping: Responses use
text/csv; charset=utf-8withContent-Disposition: attachment; filename="usage-export-<from>-<to>.csv"(timestamps asYYYYMMDDTHHMMSSZ), one header row, CRLF line endings, UTF-8 without BOM, and minimal RFC 4180 quoting. Any field starting with=,+,-,@, tab, CR, or'is prefixed with a leading'before quoting so spreadsheets treat it as plain text; consumers needing exact strings should strip one leading'from fields starting with'. JSON output is never modified. - Model share (
share_of): Adds"share": {"models": [...], "numerator_output_tokens": N, "denominator_output_tokens": D, "share": N / D}to JSON responses (nullwhenD == 0). Both sums exclude cache hits (cache_hit_typeset, rule R7). Unfiltered queries include all outcomes (rule R9) and all purposes (rule R8), including guardrail and classifier calls; explicitoutcome,purpose,model, orbackend_idfilters narrow both numerator and denominator equally.
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://127.0.0.1:8080/admin/usage/export?group_by=period:1d,model,backend_id,outcome&format=csv" \
-o usage.csv
Usage reconciliation¶
GET /admin/usage/reconciliation compares output tokens recorded in the usage ledger against each backend engine's own generation counter, window by window, as configured under usage.reconciliation. When disabled or unconfigured, it returns 200 with {"enabled": false, "backends": {}}. The response never contains model names, key IDs, or prompt/completion text.
| Field | Description |
|---|---|
windows | Newest-first list (up to history entries per backend). Each window reports result as within_tolerance, drift, no_engine_data (no fresh scrape or initial baseline sample), or reset (engine restart detected). |
engine_generation_tokens | Engine counter delta for the window, or null when result is no_engine_data. |
drift_ratio | (engine - ledger) / max(engine, 1) (positive when the ledger under-counts); null unless result is within_tolerance or drift. |
totals | Cumulative ledger_output_tokens, engine_generation_tokens, and drift_ratio across all comparable windows (within_tolerance, drift, and reset) since reconciler startup or last backend reset. Totals are approximate because in-flight requests can straddle window boundaries and reset windows record pre-restart tokens on the ledger side only (see Reconciliation against engine counters). |
dropped_samples | Count of ledger samples dropped due to a full reconciler queue (a non-zero value indicates ledger under-counting). |
{
"enabled": true,
"window": "5m",
"max_drift": 0.05,
"history": 24,
"dropped_samples": 0,
"backends": {
"vllm-a": {
"totals": {"ledger_output_tokens": 48211, "engine_generation_tokens": 48230, "drift_ratio": 0.0004},
"windows": [
{"backend": "vllm-a", "window_start_ms": 1791700000000, "window_end_ms": 1791700300000,
"ledger_output_tokens": 9120, "engine_generation_tokens": 9124, "drift_ratio": 0.0004,
"result": "within_tolerance"}
]
}
}
}
Routing, cache, and guardrail controls¶
| Method | Path | Purpose |
|---|---|---|
GET | /admin/prefix-routing/stats | Prefix-routing statistics |
GET | /admin/provider-failures | Recent provider failures per backend (see below) |
GET | /admin/response-cache/stats | Response-cache statistics |
POST | /admin/response-cache/invalidate | Invalidate response-cache entries |
GET | /admin/kv-index/stats | KV index statistics |
GET | /admin/kv-index/backends | Per-backend KV index state |
POST | /admin/kv-index/clear | Clear the KV index |
GET | /admin/gemini-context-cache/stats | Gemini context-cache statistics |
POST | /admin/gemini-context-cache/clear | Clear Gemini context-cache state |
GET, PUT | /admin/smart-routing/model-profiles | List or replace model profiles |
GET | /admin/smart-routing/model-profiles/{model} | Read one model profile |
GET | /admin/smart-routing/status | Smart-routing status |
GET | /admin/smart-routing/stats | Smart-routing statistics |
POST | /admin/smart-routing/classify | Classify a request for inspection |
POST | /admin/smart-routing/simulate | Simulate routing |
GET, PUT | /admin/smart-routing/policies | Read or replace policies |
GET | /admin/smart-routing/load-state | Backend load state |
GET | /admin/smart-routing/cache/stats | Classifier-cache statistics |
POST | /admin/smart-routing/cache/clear | Clear the classifier cache |
GET, PATCH | /admin/guardrails | Read or patch guardrail policy |
PUT | /admin/guardrails/providers/{name} | Update a guardrail provider |
PUT, DELETE | /admin/guardrails/routes/{route} | Set or remove a route override |
POST | /admin/guardrails/test | Dry-run text through configured guardrails |
GET /admin/provider-failures lists the most recent error responses each backend returned, so a failing fallback chain can be debugged without correlating request traces. Every backend hop that answers an HTTP error status is recorded, whichever route, retry, or fallback hop sent it: the status, a redacted summary of the provider's error message (credentials and internal endpoints are removed before it is stored, and it is cut at 200 characters; empty when the body was unreadable), and the time in Unix milliseconds. Each backend keeps its 20 most recent failures, most recent first; at most 256 backends are tracked, and a backend removed from the pool leaves the list. The data is process-local and resets on restart.
{
"backends": {
"provider-openai": [
{"status": 429, "summary": "Rate limit reached for gpt-5.6-terra", "at_ms": 1761700000123},
{"status": 401, "summary": "Incorrect API key provided: [redacted]", "at_ms": 1761699990456}
]
}
}
The provider_failures_total{backend, status} counter in Metrics counts the same events.
Smart-routing status and diagnostics¶
GET /admin/smart-routing/status reports the smart-routing state the Router executes. Under Hub governance that is the composition of the local smart_routing: section and the Hub policy, so the top-level fields can differ from the local file; the local values are reported under configured. When smart routing is not running, the endpoint answers enabled: false with a message, plus the same source, hub_governed, and configured fields.
| Field | Content |
|---|---|
enabled | Whether the live smart router is enabled; a section refused at apply time reads false here while configured.enabled is true |
source | hub while a Hub smart-routing policy is executed, local otherwise (no Hub, a Hub clear, or a declined Hub policy) |
hub_governed | Whether the control-plane reconciler owns smart routing on this Router |
smart_routing_policy_digest | The executed Hub policy digest, the same value acknowledged to the Hub; null when source is local |
declined_hub_policy | A Hub policy the Router declined, as smart_routing_policy_digest and error_code; null otherwise |
virtual_model, intercept_all, classifier_method, policy_count | The effective values the live router uses |
default_tier, profile_count | From the live tier registry; profile_count counts exact and auto-inferred profiles |
has_llm_classifier, has_system_one_classifier, system_one_shadow | The live classifier state |
load_state, load_monitoring_enabled, debug_headers | Load monitor state and the debug-header switch |
configured | The local section: enabled, virtual_model, intercept_all, default_tier, classifier_method, system_one_shadow, load_monitoring_enabled, policy_count, profile_count (configured model_profiles entries), and unprofiled_models |
unprofiled_models, candidate_models, excluded_unprofiled_models, excluded_incompatible_models | The live smart_routing.unprofiled_models value, the healthy user-visible models smart routing may choose from (each with model, tier, and profile source), the models left out because they have no profile under exclude, and the models left out because their metadata advertises decisions without text or chat output; models served only by internal or disabled backends, and variant models a hide_variant_models: true experiment reserves, never appear |
POST /admin/smart-routing/classify and POST /admin/smart-routing/simulate take {"payload": <chat completion body>}; simulate also accepts key_tier, org, and headers to evaluate identity conditions. Classify returns the classification object, and simulate returns the same object under classification, built by one serializer, so a when.language or when.requires policy can be checked from simulate alone:
| Classification field | Content |
|---|---|
complexity, domain, confidence, classifier_type | The classification and the classifier that produced it |
language | Short BCP-47 tag of the user's text, or null when it carried no letters |
required_capabilities | Capabilities the request needs, such as vision or function_calling |
reasoning | The classifier's explanation, when it gives one |
signals | Each detected signal with name, strength, and influences |
Simulate also returns routed, rejected, target_model, policy (name, tier, prefer_domains, require_capabilities), load_state, input_tokens (the estimate a when.input_tokens policy is matched against, null when no loaded policy reads it), classification_duration_ms, and available_models; a rejected or unroutable request carries reason instead of a classification.
Files, ACP, and control plane¶
| Method | Path | Purpose |
|---|---|---|
GET, POST | /admin/files | List or upload files; 404 when Files is disabled |
GET, DELETE | /admin/files/{file_id} | Read metadata or delete a file |
GET | /admin/files/{file_id}/content | Download file content |
GET | /admin/acp/status | ACP service status |
GET | /admin/acp/sessions | Active ACP sessions |
GET | /admin/acp/agent.json | ACP agent descriptor |
GET | /admin/control-plane/status | Control-plane agent status; route exists only in a control-plane build |
GET | /admin/control-plane/policy | Hub policy the Router is executing, without key hashes or secrets; route exists only in a control-plane build |
POST | /admin/control-plane/enroll | Privileged router-side Hub enrollment; route exists only in a control-plane build |
Note: Files uploaded through
POST /admin/filesare attributed to the synthetic admin owner (__admin__) rather than left ownerless. Withfiles.auth.enforce_ownershipon, a tenant key cannot read or delete an admin-uploaded file from the public Files API, and admin-scope keys reach it throughadmin_can_access_all. The same policy applies when an API request references a file.
Hub policy view¶
GET /admin/control-plane/policy reports the Hub policy the Router is executing, under the same Admin authentication as the other endpoints. It answers {"enabled": false} while the agent is disabled and {"enabled": true, "policy_sync_enabled": false} while control_plane.policy.enabled is off. Otherwise synced says whether an envelope has been applied, and a Router that never enrolled reports synced: false with empty lists.
| Field | Content |
|---|---|
revision, issued_at_ms, last_sync_ms | Cursor of the applied envelope, when the Hub issued it, and when this Router applied it |
status | The capabilities and the active and rejected revision identities the Router reports to the Hub; status.rejected names a refused revision with its error code |
tiers | Each tier's limits as the Hub stated them, with key_count and revoked_key_count |
keys | total, revoked, and unresolved_tier (keys whose tier the envelope does not define) |
substitution_rules, equivalence_classes, routing_windows | The effective sets: an envelope that omits a set keeps the previous one |
optimization | The org optimization policy; null before the first sync, or when the applied envelope carries no optimization block (the router then runs its defaults). Inside the block, semantic_similarity_bps and cache_ttl_secs are omitted when the Hub did not state them; the router uses its default |
model_budgets | The model-budget unit in force, with tracked_keys, exhausted_keys, and unknown_keys per pool; null when the Router enforces none |
restored | Set while a key table restored from the opt-in control_plane.policy.persist_key_table sidecar serves in place of a synced envelope: revision, issued_at_ms, synced_at_ms (the staleness origin), restored_at_ms, expires_at_ms, and key_count. synced is false meanwhile, and only tiers and keys are populated; null otherwise |
Key hashes and provider credentials are never included; keys appear only as counts. The Router keeps enforcing the last applied policy while the Hub is unreachable, so read heartbeat from GET /admin/control-plane/status beside this view to tell a current policy from the last known one. GET /admin/control-plane/status also reports policy_sync.source (synced, restored, or none), the same restored block under policy_sync.restored, and, when persistence is on, policy_sync.key_table_persistence with last_persisted_ms, write_failures, and max_age_secs.
Router-initiated Hub enrollment¶
POST /admin/control-plane/enroll uses the same Admin authentication and audit middleware as every other Admin endpoint. Its JSON body accepts a required token, optional hub_url and router_name, and optional replace boolean:
{"token":"single-use-token","hub_url":"https://hub.example.com","router_name":"edge-a","replace":false}
The Router exchanges the token over HTTPS (loopback HTTP is accepted for development), persists only the Hub-issued tenant_id, router_id, and router_credential through the existing owner-only credential store, and returns state: "pending_restart". The enrollment token is never persisted, echoed, or logged and the WebUI clears it after every attempt. Restart the Router to activate the new identity.
A healthy persisted credential returns 409 ALREADY_ENROLLED unless replace: true is explicit. A credential that the running agent has observed as rejected is reported as rejected by GET /admin/control-plane/status and may be replaced without deleting the state file or setting replace. The endpoint serializes its exchange with startup enrollment so concurrent attempts cannot overwrite one another.
The control_plane section is startup-owned: it remains readable through GET /admin/config/control_plane, but is omitted from GET /admin/config/sections, and PUT/PATCH /admin/config/control_plane return 409 STARTUP_OWNED_SECTION with guidance. Continue using the source YAML/TOML and a restart for enabled, policy, cadence, state-file, and other agent settings. Existing config-file token enrollment remains supported.
Refused configuration mutations¶
A request that asked to change running state and was refused because of the value it submitted answers 400 Bad Request. A request that asked for a verdict rather than a change keeps 200, with the outcome in the body. The dividing line is the request, not the endpoint:
| Request | Refusal status |
|---|---|
PUT/PATCH /admin/config/{section} | 400 |
POST /admin/config/import with apply: true and dry_run: false | 400 |
POST /admin/config/import with dry_run: true, or with apply: false | 200, verdict in success and validation |
POST /admin/config/apply with a config candidate and hot_reload: true | 400 |
POST /admin/config/apply with no candidate, an unchanged candidate, or hot_reload: false | 200, verdict in success and message |
POST /admin/config/validate | 200 for every parseable input, verdict in valid and errors |
| Any mutating request above when the router has no hot-reload channel | 503, body unchanged ("success": false) |
The response body includes "success": false and the descriptive error/validation.errors fields, while the 400 status code ensures that automation keying on the status code (curl -f and standard HTTP client libraries) treats a refused configuration mutation as an error rather than an applied change.
When the router runs without a hot-reload channel, a request that would change running state answers 503 Service Unavailable with "success": false and the "Hot reload not available" message, because the request is valid and the missing capability is the server's. This covers PUT/PATCH /admin/config/{section}, POST /admin/config/import with apply: true and dry_run: false, POST /admin/config/apply with a changed candidate and hot_reload: true, POST /admin/config/rollback/{version} (which then records no history version), and the backend mutations (POST /admin/backends, PUT/DELETE /admin/backends/{name}, and the weight and models sub-routes). Previews and dry runs keep 200, since they apply nothing. The import size and nesting guards carry their own SIZE_LIMIT_EXCEEDED and NESTING_LIMIT_EXCEEDED codes.
Status codes¶
200: request processed. A verdict endpoint reports its verdict here, soPOST /admin/config/validate, aPOST /admin/config/importdry run, and aPOST /admin/config/applypreview can still carry"valid": falseor"success": falsein the body.400: malformed request, or a configuration mutation the router refused because of the submitted value (with"success": falsein the JSON body).401: missing or invalid Bearer/Basic credentials.403: IP not allowed or insufficient authorization.404: unknown resource, feature-disabled runtime service, or feature-gated route absent.409: existing credential requires explicit replacement, router name conflict, or startup-owned runtime section.422: the Hub rejected the supplied enrollment token.413: upload exceeds the route body limit.502: the outbound Hub enrollment exchange failed.500: internal failure or an authentication method missing required runtime state.503: a configuration or backend mutation that cannot be applied because hot reload is unavailable (with"success": falsein the JSON body where the endpoint returns one).