Routing rules
A routing rule decides which model answers each request to a Virtual model. Every Virtual model has one routing rule. Built-in and BYOK models don't need one, because they always answer their own requests.
Routing rules run after rewrite rules, so they see the rewritten conversation. A routing rule only picks a model. It can't change the request.
The Static rule
Static is built into Radius. It sends every request to one model you select. cheap, balanced, and precise use it.
If the selected model can't serve a request, the request fails. Radius doesn't silently switch to a different model.
Custom routing rules
A custom routing rule picks a model for each request. For example, it can send requests with images to a model that supports images, long conversations to a model with a large context window, or short questions to a cheaper model.
Custom rules are small JavaScript modules your organization publishes through the Radius API. The Routing rules page, linked from Models, shows each rule's source, settings, and the models that use it, including for Static. For custom rules, it also shows earlier versions.
To create one, ask Pi. Set up Pi as described in Manage models with an agent, then describe the rule you want. Pi uses these operations:
routingRules.testruns a draft rule against a sample request.routingRules.publishpublishes it.models.upsertselects it in a Virtual model. You can also select it in the Virtual model's settings on the Models page.
Owners and admins can publish and delete rules.
Write a routing rule
A rule default-exports an object with a route(input) method, which can be async. It returns the ID of one candidate from input.candidates:
export default {
route(input) {
const preferred = input.candidates.find(
(candidate) => candidate.routeId === input.profile.routing.config.model,
);
return {
candidateId: preferred?.id ?? input.defaultCandidateId,
reason: preferred ? "Preferred model is available" : "Using the default candidate",
};
},
};
This rule sends requests to the model set in the Virtual model's model setting (see Rule settings), and falls back to the first candidate when that model isn't available.
Input
profile.routing.config: the settings of the Virtual model that received the request.request: information about the request and the conversation. See Request input.candidates: the choices for this request. Each candidate is one way Radius can serve a model, so a model can appear more than once. Each has:id: the ID to return.routeId: the model ID, such asdeepseek-v4.1-flash.name,providerId, andupstreamModel.billing:"credits", or"byok"when it uses your organization's provider key.supportsImages,supportsReasoning,contextWindow, andmaxTokens.cost: price in US dollars per million tokens, withinput,output,cacheRead, andcacheWrite.
defaultCandidateId: the ID of the first candidate.
Use routingRules.test to see the input for a sample request. A few fields differ in tests; see Testing details.
Result
Return { candidateId, reason? }, where candidateId is a candidate's id, not its routeId. reason is an optional explanation.
If the rule returns an unknown candidate or an invalid result, throws, or times out, the request fails. Radius never picks a different model for you, so build any fallback into the rule.
Limits
Rules run in a sandbox without network access, with up to 50 ms of CPU time and a 1 second timeout. A rule's top-level variables can carry over from one request to the next, including requests from other people in your organization. This isn't guaranteed, so don't rely on it, and don't keep conversation data in top-level variables.
Rule settings
A rule can declare settings, so each Virtual model that uses it can configure it differently. The Virtual model's settings page shows them as form fields, and the rule reads the values from input.profile.routing.config.
Declare settings in config_schema when you publish. For example, this routingRules.publish body declares the model setting used above:
{
"rule_id": "preferred-model",
"name": "Preferred model",
"description": "Sends requests to the configured model when it is available.",
"source": "export default { route(input) { … } };",
"config_schema": {
"version": 1,
"fields": [{ "key": "model", "label": "Preferred model", "kind": "model" }]
}
}
A Virtual model using this rule then has settings such as { "model": "deepseek-v4.1-flash" }. Rules without settings must declare { "version": 1, "fields": [] }.
Each field has a key, a label, and one of these kinds:
model: one model ID.models: a list of model IDs, which can be empty.number: any number. Check ranges in your rule.select: one value fromoptions, a list of{ value, label }objects.
Radius checks a Virtual model's settings against the schema when you save the Virtual model. When you publish a new version, Radius checks it against every Virtual model that uses the rule, including disabled ones. If any of their settings no longer fit, publishing fails and nothing changes. Update those models first, or publish the change under a new rule_id. See Settings validation for the exact rules.
Test a routing rule
routingRules.test runs a rule against a sample request without publishing it, charging credits, or calling a model. Test a draft by sending source, config_schema, and config:
{
"source": "export default { route(input) { return { candidateId: input.defaultCandidateId }; } };",
"config_schema": { "version": 1, "fields": [] },
"config": {}
}
To test a published rule, send its rule_id with either config or profile, the ID of a Virtual model that uses the rule. With profile, the test uses that Virtual model's saved settings.
See Testing details for what a test does and doesn't cover.
When routing rules run
A rule runs once per user message. Follow-up requests in the same reply, such as tool calls and retries, usually reuse that choice for up to an hour. This needs a session ID: without one, the rule runs on every request. Radius can't always reuse a choice, so a rule should return the same answer for the same conversation. See Caching for exactly when a rule runs again.
When you publish a new version or change a Virtual model's settings, new messages use the change within moments, and within an hour at most. A reply that is already in progress usually keeps the model it started with.
Update or delete a rule
Publishing again with the same rule_id creates a new version and switches every Virtual model that uses the rule to it. The Routing rules page shows earlier versions, and so do routingRules.versions.list and routingRules.versions.read. Viewing an earlier version doesn't make it active again: to go back, publish its source as a new version.
To delete a rule, use its page or routingRules.revoke. You can't delete a rule that an enabled Virtual model uses.
Reference
Request input
input.request contains these fields. Values that come from the conversation reflect it after rewrite rules have run.
| Field | Type | Meaning |
|---|---|---|
model |
string |
The ID of the Virtual model that received the request. |
estimatedInputTokens |
number |
Estimated size of the conversation in tokens. |
messageCount |
number |
Number of messages in the conversation. |
hasImages |
boolean |
Whether the request contains images. |
hasTools |
boolean |
Whether any tools are defined. |
toolNames |
string[] |
Names of the defined tools. Empty when there are none. |
reasoningRequested |
boolean |
Whether the request asks for thinking. |
maxOutputTokens |
number (optional) |
The request's output token limit, if set. |
hasSessionId |
boolean |
Whether the request has a session ID. The ID itself isn't included. |
lastUserText |
string (optional) |
Text of the most recent user message that has text. |
context |
object |
The conversation, as described below. |
lastUserText comes from the most recent user message that contains text. Text blocks are joined with newlines, images are ignored, and surrounding whitespace is trimmed. Empty and image-only messages are skipped, so the text can come from an earlier message. The field is omitted when no user message has text. Assistant messages and tool results don't affect it, so a follow-up request after a tool call has the same lastUserText as the request before it.
context contains:
systemPrompt?: stringmessages: the conversation, where each message is one of:{ role: "user", content: string | ContentBlock[], timestamp?: number }{ role: "assistant", content: ContentBlock[], model?: string, timestamp?: number }{ role: "toolResult", content: ContentBlock[], toolCallId?: string, toolName?: string, isError?: boolean, timestamp?: number }
tools?: Array<{ name: string; description?: string }>
ContentBlock is one of:
{ type: "text", text: string }{ type: "image", mimeType?: string, omittedData: true }{ type: "thinking", text: string, redacted?: boolean }{ type: "toolCall", name: string, id?: string }
Image data, tool-call arguments, tool parameter schemas, signatures, and request metadata are left out. The system prompt, lastUserText, each user message string, and each text or thinking block are each cut to 64,000 JavaScript UTF-16 code units, ending in …. There is no limit on their combined length, and tool names and descriptions aren't cut.
Caching
Radius caches a rule's choice so that the follow-up requests of a reply use the same model:
- A turn starts with each new user message, including messages that only contain images. Assistant messages, tool results, and retries continue the same turn.
- Choices are kept separately for each organization, user, session, Virtual model, and conversation up to the latest user message. Two branches of the same conversation keep separate choices.
- The conversation is compared before rewrite rules run. Editing or compacting earlier messages starts a new turn, but changing rewrite rules doesn't.
- When a cached choice is used, the rule doesn't run at all. Rewrite rules still run on every request.
- A choice expires one hour after it's made. Using it doesn't extend that.
- Nothing is cached when a request has no session ID or no user message. The rule then runs on every request.
- Caching is best effort. A rule can run again at any time, for example when a cached choice was evicted or the chosen model can no longer serve the request. Two requests at the same moment can get different choices.
- Publishing a new version or changing a Virtual model's settings doesn't clear cached choices. A turn in progress keeps its model until its choice expires.
Settings validation
Schemas:
- Each version stores its source and schema together. Send the complete schema every time you publish.
source_sha256covers only the source. - Keys start with a letter and contain only letters, digits, and underscores.
__proto__,constructor, andprototypearen't allowed. Keys must be unique. - Labels can't be blank.
selectfields need at least one option, and option values must be unique. - Fields can't have defaults or descriptions, can't be optional, and can't contain nested settings.
- Publishing checks the schema but doesn't run the source, so test before you publish.
Settings values:
- Every field needs a value, and keys the schema doesn't declare are rejected.
- The API takes typed JSON, such as
5for a number, not"5". The settings page converts form input for you. modelandmodelsvalues must be models in your organization's catalog.modelslists can't contain duplicates, and their order has no meaning.- Model IDs in settings don't have to be available for every request. A rule can fall back when a configured model isn't among the candidates.
Schema checks can't tell whether your rule still behaves correctly after a change, so review code changes as well.
When a request arrives, Radius checks the Virtual model's settings against the exact version it's about to run. If they don't match, for example because a new version was published at the same moment, the request fails instead of running the rule with unchecked settings.
Testing details
- Inline tests always need
config, even{}for a rule without settings. - When you test a published rule with
rule_id, don't sendconfig_schema. Radius uses the published one. - A test uses the Virtual model in
profile, or the one inrequest.modelif you don't sendprofile.request.modeldefaults tobalanced. The test applies that model's maximum thinking level. Your organization's current candidates are used either way. - Tests don't run rewrite rules, so the rule sees the sample conversation unchanged.
- If the test's Virtual model uses Static,
defaultCandidateIdis the candidate for that Static model, not the first candidate. Sendprofileset to a Virtual model that uses your rule to get the same default as real requests. input.request.modelis the test request'smodel, even when you sendprofile.- In the test response,
input.profileuses different names than the rule receives:routing.ruleIdinstead ofrouting.componentId, andrewriteRulesinstead ofcontextRewritingComponents.routing.configis the same. - Tests run in the same sandbox and with the same checks as real requests, but never use or save cached choices.
Rule IDs
Custom rule IDs use lowercase letters, digits, and hyphens, start and end with a letter or digit, and are up to 64 characters long.
Errors
| Situation | Response |
|---|---|
| Saving a Virtual model with settings that don't fit the rule | HTTP 400, error: "invalid_routing_rule_config", fields: [{ field, message }] |
| Publishing a version that doesn't fit existing Virtual models | HTTP 409, error: "routing_rule_config_incompatible", referencing_profiles: [{ profileId, fields: [{ field, message }] }] |
| Deleting a rule that an enabled Virtual model uses | HTTP 409, error: "routing_rule_in_use", referencing_profiles: [profileId] |
Testing with a malformed request, schema, or config that isn't an object |
HTTP 400 |
| Testing with settings that don't fit the schema | HTTP 200, ok: false, phase: "configuration", details.fields |
| Testing a rule that fails | HTTP 200, ok: false, message, and a phase: resolve, setup, load, execute, or decision |