Tutorial: Browse groups, dispatch services, enroll, build collections¶
This tutorial walks through the customer SDK's main verbs:
- Browse a service group and its member services
- Dispatch a one-shot request through a service's interface
- Enroll in a service with your own upstream credentials (BYOK)
- Schedule a recurring dispatch
- Create your own collection — a customer-curated group of
subscribed services, addressable at
/g/<name>
We'll use a hypothetical llm group throughout. Adjust names to
match what's actually configured on your account.
Setup¶
import os
from unitysvc import Client
client = Client(api_key=os.environ["UNITYSVC_API_KEY"])
# or: client = Client.from_env()
The API key encodes your customer identity — no separate
customer_id is required anywhere in the SDK.
1. Browse a group¶
Groups are the discovery entry point. They're identified by a
slug name (e.g. "llm", "vision-api") — names are stable
across admin-side recreations, while group UUIDs aren't, so SDK
scripts should hardcode the slug rather than the UUID.
client.groups.get(...) returns a Group object with bound
navigation methods, so you can chain calls without re-passing the
slug:
llm = client.groups.get("llm")
print(llm.name, llm.display_name)
print("services:", llm.service_count)
# Drill into members (cursor-paginated). Items are `Service`
# wrappers ready for further navigation.
page = llm.services()
for svc in page.data:
print(f" {svc.name} ({svc.provider_name})")
# Next page:
if page.has_more:
page = llm.services(cursor=page.next_cursor)
grp.services() is the canonical service-discovery path —
there's intentionally no flat client.services.list(), because
services are only meaningful within the context of a group.
Need more than just names? Narrow the result with search=:
2. Dispatch a one-shot request¶
Pick a member service, look at its interfaces, and call it. Items
in page.data are already Service wrappers, so you can chain
calls directly:
svc = page.data[0] # already a Service wrapper
ifaces = svc.interfaces()
for i in ifaces:
print(f" {i.name} base_url={i.base_url} enrollment={i.enrollment_id}")
response = svc.dispatch(
json={"messages": [{"role": "user", "content": "hello"}]},
)
print(response.status_code, response.json())
If you only have a service id (e.g. from a webhook payload),
fetch a wrapper first: svc = client.services.get(service_id).
Interface-resolution rule¶
- Multiple public interfaces all map to the same upstream — the
SDK auto-picks one. No
interface=required in the common case. - One enrollment-bound interface (e.g. after BYOK) → preferred over public interfaces; the customer enrolled to use their own key/parameters.
interface=is only required to disambiguate when the customer has 2+ enrollments on the same service.enrollment=is an equivalent hint that picks byenrollment_id.
Group-level dispatch¶
If the group itself has an access interface (a single entry on
group.interface), you can dispatch without picking a specific
service — the gateway applies the group's routing_policy to
select a member on each call (weighted random, by price, by
latency, by content, ...).
This is the recommended path for multi-provider groups where customers don't want to pick a specific service themselves.
Dry-run with resolve()¶
Before dispatch, you can ask the gateway which service it would pick without making the upstream call:
plan = client.resolve(
path="v1/chat/completions",
routing_key={"model": "gpt-4"},
)
for c in plan.candidates:
print(f" {c.service_name} @ weight={c.weight}")
if plan.selected:
print(f"→ would pick: {plan.selected.service_name}")
Same candidate identity the real dispatch uses, minus the actual
HTTP call and sensitive fields (wallet_id, upstream API keys,
and so on).
3. Enroll with BYOK / BYOE¶
"Bring Your Own Key" services require per-customer credentials. Enrolling creates a customer-bound access interface that the gateway uses to substitute your key at dispatch time.
Before enrolling, check what the service needs:
print("required:", svc.required_secrets()) # e.g. ["MY_PROVIDER_API_KEY"]
print("optional:", svc.optional_secrets()) # list of {"name", "default"}
Set the required secrets on your account, then enroll:
client.secrets.set(name="MY_PROVIDER_API_KEY", value="sk-...")
# Active-record enrollment — pre-binds the service id.
enr = svc.enroll(parameters={
"endpoint": "https://my-host.example",
})
print("enrolled:", enr.id, enr.status) # "pending" initially
# Poll for activation (a few seconds):
import time
while enr.status == "pending":
time.sleep(1)
enr = enr.refresh()
print("now:", enr.status) # "active", "incomplete", or "pending"
# Dispatch — picks the enrollment-bound interface automatically.
resp = svc.dispatch(json={"messages": [...]})
Parameters that look like secrets (api_key, password, token,
secret_key, ...) are returned masked on reads — the server
keeps the raw values.
To stop using an enrollment:
enr.cancel()
# The interface is preserved so re-enrolling with the same
# parameters reactivates it.
4. Schedule a recurring dispatch¶
service.schedule() is service.dispatch() + a recurrence spec.
Same interface-resolution rule; instead of making the call now, the
server runs it on your schedule.
# Every 5 minutes:
sched = svc.schedule(
recurrence={"schedule_type": "interval", "interval_seconds": 300},
json={"messages": [{"role": "user", "content": "ping"}]},
name="chat-ping",
)
print("scheduled:", sched.id, sched.status) # "active"
# Cron (if your service allows it):
sched = svc.schedule(
recurrence={
"schedule_type": "cron",
"cron_expression": "0 */6 * * *",
"timezone": "UTC",
},
json={"prompt": "daily summary"},
name="summary-job",
)
# Inspect / manage via the recurrent_requests namespace:
all_scheduled = client.recurrent_requests.list()
client.recurrent_requests.trigger(sched.id) # fire once on demand
client.recurrent_requests.delete(sched.id) # stop and remove
Scheduled requests inherit the same auth (your API key) and interface resolution as one-shot dispatch. Per-service limits on minimum / maximum interval and cron usage are enforced server-side.
5. Create and manage your own collection¶
The groups you browse in section 1 are platform groups —
admin-curated, read-only. You can also create your own groups,
called collections: editable, customer-curated catalogs of services
you're subscribed to, addressable at /g/<your-collection-name> just
like a platform group. The common use case is pointing a third-party
tool (LiteLLM, LangChain, an IDE plugin) at a single base URL whose
/models endpoint returns exactly the set you picked.
Both kinds show up in client.groups.list(). Each row carries an
owner_type ("platform" or "customer") and an editable flag, so
you can tell yours apart and filter with owner=:
# Everything you can see (default):
client.groups.list() # owner="all"
# Just the platform catalogue:
client.groups.list(owner="system")
# Just your own editable collections:
mine = client.groups.list(owner="own")
for row in mine.data:
print(row.name, row.owner_type, row.editable, row.member_count)
Create a collection and add members¶
create returns the new collection as a Group. Members must be
services you're subscribed to (you have an active enrollment) — adding
an unsubscribed service is rejected. A collection holds up to 20
members.
coll = client.groups.create(
name="my-llms", # the /g/<name> slug; lowercase, [a-z0-9_-]
display_name="My LLMs",
)
# Add a subscribed service. The optional routing_key OVERRIDES the
# service's native model id so you can expose uniform names across
# providers — here OpenAI's gpt-4o is surfaced to your apps as
# "fast-gpt".
client.groups.add_member(
coll.id,
service_id=openai_gpt4o_service_id,
routing_key={"model": "fast-gpt"},
)
# Omit routing_key to keep the service's native model id.
client.groups.add_member(coll.id, service_id=claude_service_id)
# Inspect membership:
for m in client.groups.members(coll.id):
print(m.service_id, m.routing_key)
Management methods are UUID-keyed — pass the collection's id
(coll.id), not its slug. (The slug is reserved for the /g/<name>
dispatch path.)
Dispatch against your collection¶
Hitting /g/my-llms routes to a member by matching the request's
model field against each member's effective routing key (your
override, or the service's native id):
mine = client.groups.get(coll.id) # resolvable by id or name
# Routes to the member whose routing key is {"model": "fast-gpt"}:
resp = mine.dispatch(json={"model": "fast-gpt", "messages": [...]})
# No `model` field → the gateway picks across all members
# (weighted by each service's access-interface weights).
resp = mine.dispatch(json={"messages": [...]})
If two members share the same model routing key, the gateway
load-balances across them. An unknown model returns a 404 listing
the collection's available ids. The collection's /models endpoint
returns the deduplicated set of those ids, so an OpenAI-compatible
client pointed at the collection sees exactly your curated names.
Update / remove¶
client.groups.update(coll.id, display_name="My Production LLMs")
client.groups.remove_member(coll.id, service_id=claude_service_id)
client.groups.delete(coll.id)
Only your own collections are editable — update / delete /
member changes on a platform group return 403.
Putting it together¶
from unitysvc import Client
with Client.from_env() as client:
llm = client.groups.get("llm")
# Option A — group-level, let the gateway pick:
resp = llm.dispatch(
json={"messages": [{"role": "user", "content": "Hello"}]},
)
print(resp.json())
# Option B — specific service + specific interface:
members = llm.services()
gpt4 = next(s for s in members.data if s.name == "gpt-4")
resp = gpt4.dispatch(
interface="chat",
json={"messages": [{"role": "user", "content": "Hello"}]},
)
print(resp.json())
Same flow from the CLI¶
Every step above has a usvc counterpart — handy for shell pipelines or
quick one-offs without writing Python:
# 1. Browse a group + its services
usvc groups list
usvc groups services llm
# 2. One-shot dispatch (body to stdout, status to stderr)
usvc services dispatch <service-id> \
--json '{"messages": [{"role": "user", "content": "Hello"}]}'
# 3. BYOK enrollment
usvc services enroll <service-id> \
--parameter api_key=sk-... \
--parameter endpoint=https://my-host
usvc enrollments list
usvc enrollments cancel <enrollment-id>
# 4. Schedule a recurring dispatch — --interval / --cron sugar over the
# full --recurrence JSON form
usvc services schedule <service-id> \
--interval 300 \
--json '{"prompt": "ping"}' \
--name "5-min ping"
# Dry-run a route to inspect candidates without sending the upstream call
usvc resolve --path v1/chat/completions --routing-key '{"model": "gpt-4"}'
See CLI Reference for the full option list.
Further reading¶
- SDK Guide — full resource reference
- SDK Reference — auto-generated from docstrings
- CLI Reference —
usvccommands