Give a team its own policy
A profile is what a virtual key may do: which models, which capabilities, which pool subset, which budget, and any access rules on top of the deployment-wide ones. The key carries who it is. The profile carries what it can do, and the key names one.
That indirection buys three things. Changing a team’s allowlist is one
edit instead of one per key. GET /admin/v1/profiles answers “what can
this key do?” without anyone reading the config file. And a Terraform
resource or a CRD has something to declare that is not a copy on every
key.
Declare the profiles
Section titled “Declare the profiles”Add this configuration:
budgets: - name: research-daily limit: 2000000 window: day cost: {type: TotalToken}
profiles: - name: research budget: research-daily allowed_models: ["o3-*", "claude-*"]
- name: contractors budget: research-daily allowed_models: ["gpt-4o-mini"] access_rules: - name: no-embeddings at: [llm] condition: 'route == "embeddings"' action: denyMint keys into them
Section titled “Mint keys into them”Run this command:
$ curl -s -X POST localhost:8485/admin/v1/keys \ -H 'Content-Type: application/json' \ -d '{"name":"alice","profile":"research"}'A mint naming a profile that does not exist is refused with 400 and a list of the ones that do, because the mistake is almost always a spelling of something real.
Omitting profile is legal and means no profile. Such a key is
unrestricted, but for the deployment-wide rules.
The token comes back once, and there is no rotate verb. See Mint, rotate and revoke a virtual key for the lifecycle, including the four ways a key stops working.
Read back what a key can do
Section titled “Read back what a key can do”Run this command:
$ curl -s localhost:8485/admin/v1/profilesThe response looks like this:
{"profiles":[ {"name":"contractors","allowed_models":["gpt-4o-mini"], "budget":"research-daily","access_rules":["no-embeddings"],"keys":12}, {"name":"research","allowed_models":["o3-*","claude-*"], "budget":"research-daily","keys":4}]}keys counts the keys in the current snapshot pointing at each
profile, revoked and expired ones included. It is a reference count,
not a licence count.
The two layers, and which one wins
Section titled “The two layers, and which one wins”Rules exist in two places.
access_rules at the top level are the deployment’s. They are
mandatory. They run first, a deny there is final, and an allow there
ends that list without skipping the profile’s. That last part
matters. If the two lists were concatenated, a profile could be
written to allow past an org-wide deny, and the org-wide deny would
not be one.
access_rules inside a profile run second and can only narrow. Within
either list it is still first match wins, so an allow before a deny in
the same list short-circuits it as it always did.
access_rules: - name: no-banned-model # nobody escapes this at: [llm] condition: 'model == "gpt-banned"' action: deny
profiles: - name: contractors access_rules: - name: no-embeddings # only contractors get this at: [llm] condition: 'route == "embeddings"' action: denyThe layering is the same at every hop a rule is at: a profile’s rule
at mcp runs after the deployment’s rules at mcp, and so on.
Rules can also read the profile a key holds, which saves labelling every key with metadata that duplicates it:
access_rules: - name: contractors-off-frontier at: [llm] condition: 'key.profile == "contractors" && model.startsWith("o3")' action: denyA profile a key still names, and you deleted
Section titled “A profile a key still names, and you deleted”Removing a profile that keys still point at does not fail the config load. That is deliberate. A key is replicated state and a profile is configuration, and a key minted an hour ago must not be able to block the edit that removes its profile.
Those keys are refused instead with 403 unknown_profile, naming the
missing profile. Without that refusal, deleting a profile would serve
its keys unrestricted. The key list flags them:
$ curl -s localhost:8485/admin/v1/keys | jq '.keys[] | select(.unknown_profile)'{"name":"orphan","profile":"deleted-last-week","unknown_profile":true}Either put the profile back or revoke the keys.
Which detectors inspect a team’s traffic
Section titled “Which detectors inspect a team’s traffic”Guardrail detectors are not all the same price. The NLP tier is a model in memory and a tokenizer run per segment, and a deployment often wants the expensive one on contractor traffic and not on its own staff’s. A profile says which of the deployment’s detectors inspect its traffic:
guardrails: detectors: - {type: pii, name: patterns} - {type: nlp, name: names, nlp: {ref: names}} rules: - name: no-pii-to-the-provider when: 'annotations.exists(a, a.score > 0.8)' requires: [patterns, names] action: deny
profiles: - name: contractors guardrails: detectors: [patterns, names] - name: staff guardrails: detectors: [patterns]A profile selects. It does not define. There is one ensemble and
one set of detector instances behind it, so both profiles above run
the same patterns. Sharing the instance lets them share the
delta-scan cache, and stops the model from being loaded once per
profile. Omitting the block means every detector. An empty list means
none of them, which is how a population is exempted from inspection.
Selecting a smaller set is not only cheaper on the request. The commit horizon a streamed response is held to is the longest span the selected detectors could still be growing into. A profile that dropped an unbounded detector also gets its stream released sooner.
requires, and why a rule has to declare what it reads
Section titled “requires, and why a rule has to declare what it reads”The staff selection above does not load. The rule requires names, and
staff drops it:
profiles[1] (staff): guardrails: cannot drop detector "names":rule "no-pii-to-the-provider" requires itThat rule does depend on both. It fires on a score, and
agreement_boost raises a score when a second detector corroborates
the first. Dropping names lowers what patterns scores, and the
rule goes quiet without ever being edited. To make staff work, write
a policy that is true without the model rather than deleting the
dependency:
rules: - name: no-pii-to-the-provider when: 'annotations.exists(a, a.score > 0.8)' requires: [patterns, names] action: deny - name: no-card-numbers # patterns finds these on its own when: 'types.exists(t, t == "pii/CREDIT_CARD")' requires: [patterns] action: denyNow staff loads, and it lost only the score rule.
A rule that omits requires depends on every detector. So a
deployment that has annotated no rules permits no selection, and says
which rule to annotate first. That default is deliberate. The other
one would let the first unannotated rule be disarmed by the first
selection, silently. A rule that reads no findings says so:
- name: no-embeddings-for-contractors when: 'key.profile == "contractors" && route == "embeddings"' requires: [] action: denyWhy this lives on the rule and not the detector
Section titled “Why this lives on the rule and not the detector”The dependency changes when the rule changes. A flag on the detector saying “nothing needs me” is a claim about rules written somewhere else, and it goes stale the first time a rule is added. Nothing makes anyone revisit the detector block, the claim becomes false, and a profile keeps dropping a detector its policy now needs.
requires is restated whenever the rule is, because it is part of the
rule. Which detectors are droppable is then derived rather than declared:
a detector may be dropped exactly when no rule names it.
Rules are not per-profile, and do not need to be
Section titled “Rules are not per-profile, and do not need to be”There is no rules: block inside profiles[].guardrails. A guardrail
rule already reads key, so it already reads key.profile:
guardrails: rules: - name: contractors-no-names-out when: 'key.profile == "contractors" && side == "response" && types.exists(t, t == "pii/PERSON")' requires: [names] action: denyA rules block on the profile would give that rule a second place to live. Profiles exist to remove that.
Per-key limits
Section titled “Per-key limits”There is no per-key override. A key that needs its own limit gets its own profile. That reads as more configuration than an override would, and it is. An override is a second place the answer can live, and profiles exist to remove that.
A per-key allowance needs nothing special. A budget’s
shared: false default already gives every key drawing on it its own
bucket, so one profile naming one budget does not pool them. Ten keys
on a two-million-token budget is ten allowances of two million, not
two million between them. See
Cap what a team spends.
Related
Section titled “Related”- Authorization, why a key carries no limits, why the two rule layers do not concatenate, and why guardrail rules have no second layer at all
- Configuration reference,
profiles[] - Test your rules before they ship
- Channels and fidelity, what
require_capspins - The commit horizon, why a narrower selection releases a stream sooner