Provision with a Setup Script
Everything the Admin UI does, the Control Plane Admin API (http://localhost:5050) does too. A provisioning script turns “click through twelve screens” into a repeatable artifact you can run against a fresh trial bundle, a CI environment, or a customer demo — and run again safely.
The reference implementation is brutor-demo-mcp/setup-llm-demo.py — a stdlib-only script that provisions a complete two-team demo (users, groups, routing, models, keys, guardrails, limits, compliance) and is safe to re-run. This page distills its patterns into a worked example using requests.
The rules that make a script re-runnable
Section titled “The rules that make a script re-runnable”- Login once, reuse the bearer token. Abort cleanly if the response says
requires_2fa— scripted login doesn’t do TOTP. - Check-then-create.
GETwith?search=first; onlyPOSTif absent. Tolerate409 Conflict(a concurrent run got there first) and re-fetch the id. - PATCH is full-replace. The limits/governance/compliance PATCH endpoints replace the whole sub-document — always send the complete desired state, never a delta.
- Parents before children. Resource-group hierarchies, groups before models (models need
group_ids), models before API keys. - Capture API-key plaintext once. It is only ever returned at creation — write it to an env file immediately.
- Seeds are create-once. If a resource exists, leave it alone — an operator may have edited it in the Admin UI since your last run. Converge only the things your script owns; never stomp.
import sysimport requests
CP = "http://localhost:5050"s = requests.Session()
# ── Step 0: authenticate ────────────────────────────────────────────resp = s.post(f"{CP}/v1/admin-users/tenant/login", json={ "username": "admin", "password": "Admin123!", "tenant_id": "default",}).json()
if resp.get("requires_2fa"): sys.exit("Admin has 2FA enabled — scripted login unsupported.")if "access_token" not in resp: sys.exit(f"Login failed: {resp}")
s.headers["Authorization"] = f"Bearer {resp['access_token']}"Tenant admins are auto-scoped by their token — no X-Tenant-ID needed on admin calls.
The worked sequence
Section titled “The worked sequence”-
End users
Section titled “End users”def ensure_user(username, password, email, display_name):found = s.get(f"{CP}/v1/admin/end-users",params={"search": username}).json()for u in found.get("users", []):if u["username"] == username:return u["id"] # create-once: don't touchr = s.post(f"{CP}/v1/admin/end-users", json={"username": username, "password": password,"email": email, "display_name": display_name,"send_welcome_email": False,})if r.status_code == 409: # concurrent create — re-fetchreturn ensure_user(username, password, email, display_name)return r.json()["user"]["id"]sales_rep = ensure_user("sales-rep", "S3cret!pass", "sales-rep@example.com", "Sales Rep") -
End-user groups
Section titled “End-user groups”def ensure_user_group(name, display_name, member_ids):found = s.get(f"{CP}/v1/admin/end-user-groups",params={"search": name}).json()gid = next((g["id"] for g in found.get("groups", [])if g["name"] == name), None)if gid is None:r = s.post(f"{CP}/v1/admin/end-user-groups", json={"name": name, "display_name": display_name,"initial_member_ids": member_ids,})gid = r.json()["id"]# Membership add IS idempotent — safe to re-assert every runs.post(f"{CP}/v1/admin/end-user-groups/{gid}/members",json={"end_user_ids": member_ids})return gidsales_team = ensure_user_group("sales-team", "Sales Team", [sales_rep])(Re-asserting membership is the one exception to “don’t touch existing rows” — the add endpoint is a no-op for existing members, so it converges without destroying operator additions.
PUT .../membersis the authoritative set variant — that one does stomp; avoid it in seeds.) -
Resource groups — parent first
Section titled “Resource groups — parent first”existing = {g["name"]: g["id"]for g in s.get(f"{CP}/v1/admin/resource-groups").json()["groups"]}def ensure_rg(name, display_name, parent_id=None, **extra):if name in existing:return existing[name]r = s.post(f"{CP}/v1/admin/resource-groups", json={"name": name, "display_name": display_name,"group_type": "team" if parent_id else "organization","inherit_resources": True,**({"parent_group_id": parent_id} if parent_id else {}),**extra,})return r.json()["id"]acme = ensure_rg("acme-corp", "ACME Corp")sales = ensure_rg("sales", "Sales", parent_id=acme, eu_ai_act_risk_tier="high")# Bind the end-user group to its workspace (409 = already bound, fine)s.post(f"{CP}/v1/admin/resource-groups/{sales}/end-user-groups",json={"end_user_group_ids": [sales_team]})Child groups inherit the parent’s resources when
inherit_resourcesis true; limits and policies are always restrictive down the chain. See Resource Groups. -
Routing group (failover)
Section titled “Routing group (failover)”ROUTING_ID = "sales-chat-routing" # you choose the slugrgs = s.get(f"{CP}/v1/admin/llm-router/routing-groups",params={"enabled_only": "false"}).json()if not any(r["id"] == ROUTING_ID for r in rgs.get("routing_groups", [])):s.post(f"{CP}/v1/admin/llm-router/routing-groups", json={"id": ROUTING_ID, "name": "Sales chat routing","description": "Primary + fallback for sales chat","mode": "chat", "routing_strategy": "simple-shuffle","enabled": True,"allowed_fails": 1, "cooldown_time": 60, "cooldown_window": 60,"num_retries": 3, "retry_after": 5, "timeout": 300.0,}) -
Models — import from the catalog
Section titled “Models — import from the catalog”Prefer catalog import over a plain create: import copies the catalog’s cost data (and media costs/whitelists) onto the model, so cost tracking works from the first request. A plain
POST /v1/admin/llmsleaves cost fields blank.def import_model(provider, model_name, display_name, mode, api_key,group_ids, **patch):cat = s.get(f"{CP}/v1/admin/llm-catalog", params={"provider_key": provider, "mode": mode, "limit": 500,}).json().get("catalog", [])entry = next((e for e in cat if e["model_name"] == model_name), None)if entry is None:# Fallback: plain create (no cost data)r = s.post(f"{CP}/v1/admin/llms", json={"name": display_name, "model_name": model_name, "mode": mode,"provider": provider, "enabled": True, "api_key": api_key,"group_ids": group_ids, **patch,})return r.json()["model"]["id"]r = s.post(f"{CP}/v1/admin/llm-catalog/{entry['id']}/import", json={"name": display_name, "enabled": True,"group_ids": group_ids, "api_key": api_key,})mid = r.json()["model"]["id"]if patch: # routing membership + param defaults aren't part of imports.patch(f"{CP}/v1/admin/llms/{mid}", json=patch)return midhaiku = import_model("anthropic", "claude-haiku-4-5-20251001", "Sales — Claude Haiku 4.5","chat", ANTHROPIC_API_KEY, group_ids=[sales],routing_group_id=ROUTING_ID, routing_weight=1, # weight ≥1 = activedefault_temperature=0.7, default_max_tokens=512,)gpt = import_model("openai", "gpt-4o-mini", "Sales — GPT-4o mini (fallback)","chat", OPENAI_API_KEY, group_ids=[sales],routing_group_id=ROUTING_ID, routing_weight=0, # weight 0 = fallback-only)(Check for an existing model by
namebefore importing — same check-then-create pattern as everything else.) -
Bind models — including the routing-group rule
Section titled “Bind models — including the routing-group rule”# The routing group materializes as a virtual model with mode=routingrouting_models = s.get(f"{CP}/v1/admin/llms",params={"mode": "routing"}).json()["models"]vmodel = next(m for m in routing_modelsif m["routing_group_id"] == ROUTING_ID)binds = [(sales, vmodel["id"], True), # what users pick in the portal(sales, haiku, False), # member — bound for access, hidden in picker(sales, gpt, False), # member — bound for access, hidden in picker]for rg, mid, visible in binds:s.post(f"{CP}/v1/admin/resource-groups/{rg}/llm-models",json={"llm_model_id": mid, "portal_visible": visible}) # 409 oks.patch(f"{CP}/v1/admin/resource-groups/{rg}/llm-models/{mid}/portal-visibility",json={"portal_visible": visible}) # converge on re-runportal_visibleonly gates the portal’s model picker — access comes from the binding itself. -
API keys — capture the plaintext once
Section titled “API keys — capture the plaintext once”keys_out = []existing_keys = s.get(f"{CP}/v1/admin/resource-groups/{sales}/api-keys").json()if not any(k["name"] == "Sales SDK key" for k in existing_keys.get("keys", [])):r = s.post(f"{CP}/v1/admin/resource-groups/{sales}/api-keys",json={"name": "Sales SDK key", "access_mode": "shared"}).json()plaintext = r.get("key_value") or r.get("full_key") or r.get("key")keys_out.append(f'export SALES_KEY="{plaintext}"')if keys_out:with open("provisioned-keys.env", "w") as f:f.write('export GW="http://localhost:8100"\n')f.write("\n".join(keys_out) + "\n")The plaintext is returned only at creation. If the key already exists, you can’t recover it — which is exactly why the existence check comes first and the file write happens in the same run.
-
Guardrails
Section titled “Guardrails”One subtlety: the create model defaults most surface toggles to true. Send a full surface map with only your intended surfaces enabled, or your “chat-only” config will silently scan ~14 surfaces.
ALL_SURFACES = ("chat_input", "chat_output", "embeddings_input","image_gen_input", "image_gen_output","audio_tts_input", "audio_tts_output","audio_stt_input", "audio_stt_output","video_gen_input", "video_gen_output","batch_input", "batch_output", "moderation_input","mcp_input", "mcp_output", "mcp_registration","skill_input", "skill_output", "a2a_inbound", "a2a_outbound",)def surfaces(*on):return {k: (k in on) for k in ALL_SURFACES}cfgs = s.get(f"{CP}/v1/admin/guardrails/configs").json().get("configs", [])if not any(c["name"] == "Sales Baseline" for c in cfgs):s.post(f"{CP}/v1/admin/guardrails/configs", json={"name": "Sales Baseline","description": "Input injection block + output PII redaction.","enabled": True,"group_ids": [sales],"surfaces": surfaces("chat_input", "chat_output"),"builtin": {"prompt_injection_enabled": True,"prompt_injection_action": "block","prompt_injection_surfaces": ["chat_input"],"pii_enabled": True, "pii_action": "redact","pii_provider": "built_in","pii_categories": ["email", "phone", "credit_card", "ssn", "iban"],"pii_surfaces": ["chat_output"],"secrets_enabled": True, "secrets_action": "block","secrets_categories": ["aws_access_key", "github_token","openai_key", "jwt", "private_key_pem"],"secrets_surfaces": ["chat_input", "chat_output"],},})Remember the two-layer gate: a check runs on a surface only where the config-level
surfacesmap and the per-check*_surfaceslist intersect. A long per-check list is inert if the config map doesn’t enable the surface. See Guardrails. -
Parameter governance and usage limits (PATCH = full replace)
Section titled “Parameter governance and usage limits (PATCH = full replace)”# Param caps — clamp, don't rejects.patch(f"{CP}/v1/admin/resource-groups/{sales}/llm-global-governance", json={"governance": {"temperature": {"max": 0.7, "enforce": "clamp"},"context_window": {"max_output_tokens": 2000, "enforce": "clamp"},}})# Usage limits — the WHOLE desired document, every runs.patch(f"{CP}/v1/admin/resource-groups/{sales}/llm-global-limits", json={"llm_global_limits": {"tokens": {"daily_limit": 100000},"throughput": {"max_requests_per_minute": 60,"cooldown_seconds": 30,"max_usd_per_minute": 0.50},"concurrency": {"max_concurrent": 5},}})These two are converge-to-desired-state endpoints — re-running them is safe if your script is the owner of these documents. If operators tune limits by hand in your environment, move them out of the script.
-
Compliance profile
Section titled “Compliance profile”s.patch(f"{CP}/v1/admin/compliance/profile",json={"frameworks": ["soc2", "gdpr_art_30"]})Five frameworks are supported —
soc2,eu_ai_act,hipaa,gdpr_art_30,iso_42001— opt-in per tenant, full-replace semantics. See Compliance.
Verify
Section titled “Verify”source provisioned-keys.envcurl -s $GW/v1/proxy/llm/models -H "Authorization: Bearer $SALES_KEY"# → the routing virtual model (and anything portal_visible) for the Sales group
curl -s $GW/v1/proxy/llm/chat/completions \ -H "Authorization: Bearer $SALES_KEY" -H "Content-Type: application/json" \ -d '{"model":"Sales chat routing","messages":[{"role":"user","content":"ping"}]}'Then log in to the portal as sales-rep and confirm the model picker shows the routing group. Re-run provision.py — the correct output is a column of “exists, skipping” and zero changes.
Going further
Section titled “Going further”brutor-demo-mcp/setup-llm-demo.py extends this sequence with semantic-cache configs, argument policies (/v1/admin/argument-policies), semantic policies (/v1/admin/semantic-policies), asset-registry sync (POST /v1/admin/assets/sync), and Traffic Data Import — all with the same idempotency discipline. Use it as the canonical crib sheet, and the Admin API reference for every endpoint and field.
