Skip to content

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.

  1. Login once, reuse the bearer token. Abort cleanly if the response says requires_2fa — scripted login doesn’t do TOTP.
  2. Check-then-create. GET with ?search= first; only POST if absent. Tolerate 409 Conflict (a concurrent run got there first) and re-fetch the id.
  3. PATCH is full-replace. The limits/governance/compliance PATCH endpoints replace the whole sub-document — always send the complete desired state, never a delta.
  4. Parents before children. Resource-group hierarchies, groups before models (models need group_ids), models before API keys.
  5. Capture API-key plaintext once. It is only ever returned at creation — write it to an env file immediately.
  6. 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.
provision.py — client + login
import sys
import 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.

  1. 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 touch
    r = 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-fetch
    return 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")
  2. 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 run
    s.post(f"{CP}/v1/admin/end-user-groups/{gid}/members",
    json={"end_user_ids": member_ids})
    return gid
    sales_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 .../members is the authoritative set variant — that one does stomp; avoid it in seeds.)

  3. 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_resources is true; limits and policies are always restrictive down the chain. See Resource Groups.

  4. ROUTING_ID = "sales-chat-routing" # you choose the slug
    rgs = 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,
    })
  5. 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/llms leaves 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 import
    s.patch(f"{CP}/v1/admin/llms/{mid}", json=patch)
    return mid
    haiku = 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 = active
    default_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 name before importing — same check-then-create pattern as everything else.)

  6. 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=routing
    routing_models = s.get(f"{CP}/v1/admin/llms",
    params={"mode": "routing"}).json()["models"]
    vmodel = next(m for m in routing_models
    if 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 ok
    s.patch(f"{CP}/v1/admin/resource-groups/{rg}/llm-models/{mid}/portal-visibility",
    json={"portal_visible": visible}) # converge on re-run

    portal_visible only gates the portal’s model picker — access comes from the binding itself.

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

  8. 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 surfaces map and the per-check *_surfaces list intersect. A long per-check list is inert if the config map doesn’t enable the surface. See Guardrails.

  9. Parameter governance and usage limits (PATCH = full replace)

    Section titled “Parameter governance and usage limits (PATCH = full replace)”
    # Param caps — clamp, don't reject
    s.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 run
    s.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.

  10. 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.

Terminal window
source provisioned-keys.env
curl -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.

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.