Skip to content

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

admin:
  auth:
    method: bearer
    bearer_token: "${ADMIN_TOKEN}"
    allowed_ips: []
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://127.0.0.1:8080/admin/health

bearer_token must be at least 16 characters. allowed_ips is optional and accepts individual IPv4/IPv6 addresses or CIDR ranges.

HTTP Basic

admin:
  auth:
    method: basic
    basic_auth:
      username: admin
      password: "${ADMIN_PASSWORD}"
curl -u "admin:$ADMIN_PASSWORD" http://127.0.0.1:8080/admin/health

IP allowlist

admin:
  auth:
    method: ip_whitelist
    allowed_ips:
      - 127.0.0.1
      - 10.0.0.0/8

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

admin:
  auth:
    method: api_key
    required_scope: admin
    allowed_ips: []
# 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": the Content-Type is not application/json; the body is not read.
  • body_omitted: "too_large": the body passed 16 KiB; body_bytes_read gives 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, so body is 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, or null before the first sample.
  • score: the combined scorer score. Absent while no scorer is registered, and a scorer that only scores after prepare() 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 advertises decisions without text or chat output.
  • unprofiled_model: no model_profiles entry matches and smart_routing.unprofiled_models is exclude.

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 request body.

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 own engine_stats.enabled override).
  • 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_file key and restart the router to persist backends created via POST /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 overlay mode preserves local runtime backends, whereas an adopted authoritative snapshot 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.headers name collides with a runtime backend's request_extensions.headers).
  • Status and file security: GET /admin/backends, single-backend reads, and mutation responses report runtime_persistence.enabled and a durability value (configured, memory, or persistent). 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 mode 0600. 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-id handling.
    • Native Anthropic: Queries GET {base}/v1/models with x-api-key and anthropic-version: 2023-06-01 (with cursor pagination over has_more and after_id).
    • Unsupported backends: Non-Codex OAuth backends and Bedrock return 501 with error.code: "model_discovery_unsupported".
  • Error codes: Unknown backend names return 404 (backend_not_found). Upstream failures return backend_authentication_failed, backend_discovery_timeout, backend_discovery_network_error, backend_discovery_parse_error, backend_discovery_http_error, or backend_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-backend request header wherever it selects among interchangeable backend candidates (see Choosing a Backend with x-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-backend if sent. Clients holding only an inference API key can detect support via the x-served-backend response 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:

  • server
  • backends
  • health_checks
  • logging
  • retry
  • timeouts
  • rate_limiting
  • circuit_breaker
  • global_prompts
  • request_params
  • admin
  • fallback
  • model_aliases
  • files
  • api_keys
  • metrics
  • routing

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": "yaml",
  "include_sensitive": false,
  "sections": []
}

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 (or null): explicit no-op. Nothing is published, no history version is recorded, and the response reports hot_reload_triggered: false with an empty updated_sections. Repeated calls never create empty history versions.
  • config present, 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_triggered is true only after a successful publish; updated_sections and requires_restart reflect the real diff. A candidate refused here answers 400 Bad Request with the refusal in message.
  • config present, hot_reload: false: preview only. The response reports the updated_sections/requires_restart diff, but nothing is published and no history version is recorded. A refused candidate keeps 200 in 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 into admin.history_dir with file permissions mode 0600, enforcing max_history_entries and max_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 to admin.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 to admin.history_dir. GET /admin/config/history merges 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.
  • null clears it, so the key follows prompt_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 on overall, every models[] entry, and every backends[] entry alongside total_requests, successful_requests, and failed_requests. attempts_total counts each dispatch that reached a backend (retries, fallback-chain hops, pre-stream streaming hops, quality-check hops, and web-search tool-loop turns) once, whereas total_requests counts client requests (for example, a request retried once after a 503 records total_requests: 1 and attempts_total: 2). Failed attempts are attributed to the dialed backend, while requests that never reached a backend record their failure under not_dispatched. Windowed queries (?window=) count attempts for requests completed within the window; snapshots missing these fields restore them as 0. 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 as overall. Under client, requests counts client requests; under internal purposes, it counts router-initiated LLM calls. Internal calls do not increment overall request counters, latency metrics, or attempts_total, but their tokens are included in overall, by_outcome, and models[] (so by_purpose token sums equal overall when window=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.enabled and admin.stats.usage_export.enabled; if either is off, the endpoint returns 404 (usage_export_disabled). Disabling admin.stats.enabled alone pauses recording and hides existing records without draining them; disabling usage_export.enabled drops the buffer. Records are stored per router in a bounded ring (usage_export.retention, default 7d; usage_export.max_records, default 1,000,000, oldest evicted first) and included in stats snapshots when admin.stats.persistence is 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 local api_key_id or anonymous, never the raw key), user_id, team_id (always null), model, requested_model (set only on control-plane builds when a Hub rule rewrote the model), backend, backend_id (resolved by backend name at stream end; null if renamed mid-stream), purpose, outcome, token_source, estimated, stream, http_status, input_tokens, cached_input_tokens, output_tokens, reasoning_tokens, latency_ms, and cache_hit_type.
    • Cache replay records: Report backend: "local-cache", output_tokens: 0, all prompt tokens under cached_input_tokens, token_source: "none", and cache_hit_type set to exact, prefix, or semantic.
    • Model name truncation: model and requested_model are truncated to 256 bytes on a UTF-8 character boundary.
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 to limit records. The internal sequence number guarantees a total order across records with empty or shared request_id values (such as out-of-request calls or multi-response /v1/realtime sessions) and persists across restarts when stats persistence is enabled. The cursor token 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 the X-Next-Cursor header (omitted on the final page).
  • Grouped mode: Returns one row per distinct key tuple, sorted by the tuple. Each row lists the group_by keys in order (period_start in RFC 3339 UTC floored to the hour or UTC day; missing values are null in JSON and empty in CSV) followed by seven metrics: requests (distinct request_id count, with out-of-scope records counted individually), attempts (record count), input_tokens, cached_input_tokens, output_tokens, reasoning_tokens, and estimated_output_tokens (output tokens from records with estimated: true). JSON responses use {"from", "to", "group_by": [...], "rows": [...]}.
  • CSV format and formula-injection escaping: Responses use text/csv; charset=utf-8 with Content-Disposition: attachment; filename="usage-export-<from>-<to>.csv" (timestamps as YYYYMMDDTHHMMSSZ), 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 (null when D == 0). Both sums exclude cache hits (cache_hit_type set, rule R7). Unfiltered queries include all outcomes (rule R9) and all purposes (rule R8), including guardrail and classifier calls; explicit outcome, purpose, model, or backend_id filters 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/files are attributed to the synthetic admin owner (__admin__) rather than left ownerless. With files.auth.enforce_ownership on, a tenant key cannot read or delete an admin-uploaded file from the public Files API, and admin-scope keys reach it through admin_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, so POST /admin/config/validate, a POST /admin/config/import dry run, and a POST /admin/config/apply preview can still carry "valid": false or "success": false in the body.
  • 400: malformed request, or a configuration mutation the router refused because of the submitted value (with "success": false in 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": false in the JSON body where the endpoint returns one).