Pick a provider. Then pick a model.
Editing an agent used to mean typing a model id into a text box, because the picker was a select over models.allow and a scaffolded host has exactly one entry in it. A provider was never a first-class thing: it was a routing key with no display name, no descriptor, and nothing that could list what it exposes. The registry below fixes that, and it brings two consequences with it that are worth more than the picker. An allow-list entry can now name a provider, and a run can now be priced at the provider that actually ran it.
Register the providers
createModelProviderRegistry mirrors the skill-provider registry one for one: a construction-time duplicate-id throw, a structural capability probe, and per-provider failure isolation into named warnings. Each provider-* package gains a <name>Provider() export that wraps its existing callable factory. The shipped factories are untouched.
import { createModelProviderRegistry } from "@nightowlsdev/core";
import { anthropicProvider } from "@nightowlsdev/provider-anthropic";
import { openaiProvider } from "@nightowlsdev/provider-openai";
import { vercelGatewayProvider } from "@nightowlsdev/provider-vercel-gateway";
// Provider ids are constrained to [a-z0-9-]. That is load-bearing, not cosmetic: it is what makes
// "qwen3.5:397b" a bare model id BY CONSTRUCTION, because its prefix contains a dot and can
// therefore never name a provider.
export const registry = createModelProviderRegistry({
providers: [anthropicProvider(), openaiProvider(), vercelGatewayProvider()],
displayNames: { "vercel-gateway": "Vercel AI Gateway" },
});
registry.describe();
// [{ id: "anthropic", displayName: "anthropic", capabilities: { canList: false } },
// { id: "openai", displayName: "openai", capabilities: { canList: false } },
// { id: "vercel-gateway", displayName: "Vercel AI Gateway", capabilities: { canList: true } }]capabilities.canList is probed structurally: a provider can be listed live if it has a models() method. Of the wrapped SDKs only the gateway has a runtime listing, so the rest contribute small hand-curated catalog entries. A listing is always the merge of live and catalog, never live alone, and each row is tagged with its source. That merge is not tidiness. An adopter's working CogView id is absent from that provider's /models while every id the endpoint does list returns a 400, so a live-only picker offers nothing but broken ids.
Provider-qualified allow-list entries
models.allow entries gain two shapes alongside bare ids, which stay valid verbatim. The grammar is total and is purely a function of the entry and the registered providers. There is no intent and no history in it.
| Entry | Reads as | Authorizes |
|---|---|---|
| *:X | bare escape | model X under any provider |
| p:X, p registered | qualified | model X under p only |
| p:*, p registered | qualified wildcard | every model p lists, after expansion |
| anything else | bare | that id under any provider, exactly as before |
Parsing splits on the first separator only, because Ollama ids contain colons and OpenRouter and gateway ids contain slashes. Provider ids are constrained to [a-z0-9-] at registry construction, which is what makes qwen3.5:397b a bare id by construction. The residual hazard is real and named: a bare entry like groq:foo, written before a groq provider is registered, becomes qualified the day one is. That is why the resolve result carries an interpretations table, and why *: exists as the explicit escape.
import { resolveModelAllowList, priceFeedFromResolved, defineSwarm } from "@nightowlsdev/core";
// Expansion is an explicit AWAITED pre-step. defineSwarm is synchronous and has no registry to
// expand against, so a wildcard is resolved here and handed back as a sealed, inspectable snapshot.
const allow = ["anthropic:*", "openai:gpt-5.5", "openai/gpt-5.5-mini"];
const resolved = await resolveModelAllowList(registry, allow, {
prices: PRICE_TABLE, // your table or a live PriceFeed. See the unpriced rule below.
allowUnpriced: false, // the default. An unpriced wildcard row is DROPPED with a warning.
});
console.log(resolved.interpretations);
// how EVERY entry parsed, so a reinterpretation is visible rather than discovered at deny time:
// { "anthropic:*": { form: "qualified", provider: "anthropic", modelId: "*", wildcard: true },
// "openai:gpt-5.5": { form: "qualified", provider: "openai", modelId: "gpt-5.5" },
// "openai/gpt-5.5-mini":{ form: "bare", modelId: "openai/gpt-5.5-mini" } }
export const swarm = defineSwarm({
// ...
models: { allow, resolved, registry },
cost: { maxSteps: 40, maxCostUsd: 5, priceFeed: priceFeedFromResolved(() => swarm.modelAllowList()) },
});Two rules that are easy to miss
Without a snapshot, a wildcard is inert. The gate is an exact match and defineSwarm is synchronous, so p:* authorizes nothing until you await resolveModelAllowList. Each inert wildcard raises a construction warning naming it, rather than letting you discover it as a deny.
An unpriced wildcard row is dropped, and only a wildcard row. An unpriced model prices at zero, which turns cost.maxCostUsd into no cap at all, and an uncapped run that looks capped is worse than a missing model. Supply the price via opts.prices, or set allowUnpriced to keep it. Bare and exact-qualified entries are never dropped, so a legacy allow-list keeps behaving identically.
Qualify the agent, and its usage prices exactly
AgentVersion.provider is a real, versioned, append-only column (migration 0027). It is authored, never derived: recovering a provider by prefix-parsing a stored id would invent providers out of the slashes in gateway ids and the colons in Ollama ids.
import { defineAgent } from "@nightowlsdev/core";
export const writer = defineAgent({
slug: "writer",
personality: "You write.",
modelId: "claude-sonnet-4-5",
provider: "anthropic", // authored, NEVER parsed out of the model id
});The version's provider is threaded into three places on every generation, in every native engine. The gate checks the canonical provider:modelId key first and falls back to the bare id. The model factory routes to the same provider the gate authorized, so selection and validation can never disagree. And the price lookup becomes prices[provider:modelId] ?? prices[modelId].
That last one is the part worth stating plainly. When the gateway, OpenRouter, and a direct provider all expose the same model id at different rates, a bare metering key cannot tell them apart. The snapshot still emits a conservative bare alias, priced at the most expensive rate per class so a cap trips early rather than never, but that alias is now only the fallback for an unqualified version. Set provider and the run is billed at that provider's own rate. It is a data change, not a code change.
The route
runner.modelProvidersRoute() serves GET /api/swarm/model-providers, and ?provider=<id> for one provider's listing, behind the same guardDefinitionAdmin hook as every other admin route — on its own "models" scope. It 404s cleanly when the engine has no wired registry, which is exactly how the studio decides whether to render the picker.
Its own scope because of what it does: an operator trusted to edit a persona is not automatically trusted to aim an outbound call at a provider's API. A grant reaches this route only by naming adminScopes: ["models"]; a grant that says nothing means ["definitions"] and is refused here with 403 { requiredScope: "models" }.
// app/api/swarm/model-providers/route.ts
export const { GET } = runner.modelProvidersRoute();
// createNextjsRunner({ ..., modelProviders: { cacheTtlMs: 60_000, rateLimit: { windowSec: 60, max: 30 } } })
// is optional. Those are the defaults.It is the one admin route whose work is an outbound call on the server's behalf, so it is hardened past the guard. There is no baseURL parameter to forget about later: the handler reads exactly one query parameter and matches it against the registry's own descriptor ids, so an unregistered id never reaches the registry at all. Where a provider points is deployment configuration, never request data. On top of that sit a TTL cache (a listing is deployment config, so one cached answer serves everyone) and a fixed-window rate limit per admin actor, built on the same decideFixedWindow primitive core already ships.
The picker, and what happens without one
import { createEndpointsClient } from "@nightowlsdev/react/studio";
// The two provider reads attach automatically once the route resolves from `base`. Nothing else
// to wire: the editor renders the two-step picker when they are present, and the free-text
// provider input when they are not.
const client = createEndpointsClient({ base: "/api/swarm" });The editor renders a provider select, then that provider's models. Rows that are not on the resolved allow-list are marked, not hidden, because an adopter's working id can legitimately be absent from a listing. Publishing one still requires the same explicit acknowledgement it always did.
The membership badge comes from the server, and that matters more than it looks. Once you write qualified entries, allowedModels() reports canonical keys like anthropic:x, which no bare model id will ever equal. A picker testing membership against that flat list would flag every qualified agent as off the allow-list and push the operator through an acknowledgement for nothing. So onAllowList is decorated by the engine against canonical keys, and only by the engine: adapters and registry.list() leave it unset, so a listing can never claim membership it was not granted.
Wire no registry and none of this appears. The engine does not offer the listing methods, the route 404s, the client omits the two reads, and the editor renders the plain free-text provider field. Every step degrades on presence, so a deployment that adopts nothing behaves exactly as it does today.
import { refreshModelAllowList } from "@nightowlsdev/core";
// A refresh takes the IDENTICAL opts as the initial resolve. Without them it would silently
// re-expand under a different policy, and models dropped as unpriced at startup would quietly
// reappear with no code change to point at.
const next = await refreshModelAllowList(registry, allow, { prices: PRICE_TABLE, allowUnpriced: false });
// ONE reference swap backs BOTH the gate and the price feed, so enforcement and pricing can never
// desynchronize: a refreshed model becomes allowed AND priced in the same step. A CostGovernor
// built after the swap reads the new snapshot; runs already in flight keep their construction-time
// prices, because a run must not be re-priced halfway through.
swarm.applyModelAllowList(next);Where to go next
@nightowlsdev/core
createModelProviderRegistry, resolveModelAllowList, priceFeedFromResolved.
Agent config surface
Where provider sits in the versioned content, and the admin route family it publishes through.
Agent Studio
The editor the picker lives in, and the client seam it reads through.
Building on Night Owls? See the source on GitHub.