TeamShared AutoRouter
base_urlhttps://router.teamshared.com/v1
Request
POST /v1/chat/completions
Authorization: Bearer tsk_…
Content-Type: application/json
{
"model": "AutoRouter",
"messages": [
{"role": "user", "content": "Rewrite this query as a CTE: SELECT …"}
]
}
- prefilterno rule fired: the turn is not empty, not a probe, not an ack, and the whole body is under 32,000 characters
- classify{"difficulty": 3, "task": "sql", "confidence": 0.88, "risk": "low", "needs_tools": false, "output": "short"}
- gate0.88 clears the 0.55 confidence floor and risk is low, so the score stands
- constrainsql at 3 is claimed by the
coderspecialist, so deepseek/deepseek-v4.1-flash answers instead of the rung-3 generalist z-ai/glm-5.3-flash; no tools, images or response_format to satisfy, and the input estimate plus the 4,096-token default output budget fits it - completeone upstream call to deepseek/deepseek-v4.1-flash, answered on attempt 1 with finish_reason stop: no fallback, no escalation
Response
HTTP/2 200
X-TeamShared-Difficulty: 3
X-TeamShared-Task: sql
X-TeamShared-Role: coder
X-TeamShared-Assigned-Model: deepseek/deepseek-v4.1-flash
X-TeamShared-Confidence: 0.88
X-TeamShared-Risk: low
X-TeamShared-Output: short
X-TeamShared-Route-Bumped: 0
X-TeamShared-Route-Fallback: 0
X-TeamShared-Upstream-Model: deepseek/deepseek-v4.1-flash
X-TeamShared-Attempts: 1
Server-Timing: validate;dur=…, classify;dur=…, upstream;dur=…
{
"model": "AutoRouter",
"choices": [ … ],
"usage": { … },
"router": {
"difficulty": 3,
"task": "sql",
"assigned_model": "deepseek/deepseek-v4.1-flash",
"role": "coder",
"confidence": 0.88,
"risk": "low",
"reason": "classified",
…
}
}
Request path
What happens to a request
An OpenAI-compatible POST /v1/chat/completions. Send AutoRouter as the model and the router decides a difficulty from 1 to 5 and a task kind, looks the pair up in a roster of ten models — five generalists, one per rung of the ladder, and five specialists that claim particular tasks at particular rungs — and forwards your original messages to that one model. The id you asked for is the id you get back. Five stages, always in this order: the first three settle on a number and a task, the last two turn them into a model and an answer.
-
Prefilter
deterministic · latest user turn only · no model call
The latest user turn is lowercased, stripped of punctuation and emoji, and whitespace-collapsed, then compared against two short word tables. Earlier turns are ignored on purpose: a
thanksafter a hard thread must not pay for a scoring hop or a frontier model. Size is measured over the whole request — system prompt, tool schemas, tool-call arguments, tool results, and 340 characters for every image — because the model has to read all of it whatever the user turn says.Latest user turn Route What the response says Empty, with no system or developer message carrying a task Short-circuit: a one-word okcompletion, no model calledprefilter: "empty"X-TeamShared-Attempts: 0X-TeamShared-Upstream-Model: prefilterping,healthz,status,readyand six moreShort-circuit prefilter: "health"Tool results with no user text and no instructions anywhere Short-circuit prefilter: "tool_ping"Whole request over 160,000 characters Rung 4, scoring skipped: a terse follow-up to a huge thread would otherwise score as easy prefilter: "size-floor"reason: "large context"thanks,ok,lgtm,got it,npand the rest of the acknowledgement tableRung 1, scoring skipped prefilter: "ack"difficulty: 1Whole request over 32,000 characters Scored as usual, then floored: a score below 3 becomes 3 bumped: truereason: "large context"Anything else Scored — -
Classify
one scoring hop · 2 s budget · cached 3,600 s
The scored text is the latest user turn — its last 8,000 characters — plus up to 1,500 characters of earlier user turns, most recent first, so a follow-up like now do all of them still reads as one request. Images become the token
[image]in the scored text only; what is forwarded upstream is untouched. With no user turn at all, an agent whose task lives in its system prompt is scored on that prompt instead. The classifier is asked for six things and never for a model id:{"difficulty": 3, "task": "code", "confidence": 0.8, "risk": "low", "needs_tools": false, "output": "medium"}difficultyis scored by the work involved, on the rubric printed with the ladder below.taskis one of eleven kinds: chat, lookup, rewrite, translate, code, sql, math, security, research, creative, other.riskis the cost of a too-cheap route — high for security, irreversible, legal or medical work, or when the classifier is guessing. Which model scores is deployment configuration: a general model answering in JSON, a typed decision endpoint that returns a probability distribution over the five levels, or an in-process heuristic that costs nothing and makes no network call.A hop that overruns its 2-second budget, fails, or returns something unparseable does not guess. The request snaps to rung 3 with
fallback: trueand a reason ofclassifier unreachableorsnapped to tier default. Successful scores are cached for 3,600 seconds against a whitespace- and case-insensitive hash of the scored text, 2,048 entries deep, so a retried or regenerated turn routes for free. A snapped fallback is never cached: it is not a decision worth repeating. -
Gate
no network · at most one rung
An unsure score buys headroom rather than a coin flip. Each of the signals below moves the assignment up one rung, and one rung in total however many of them fire, capped at 5. A rung the prefilter assigned, or a snap to the middle because scoring failed outright, is left where it is. A bump is looked up in the roster again at the new rung, so a specialist that claims the task only at the lower rung gives way to whichever model claims it at the higher one.
Signal Effect What the response says confidencebelow 0.55Up one rung bumped: trueX-TeamShared-Route-Bumped: 1riskofhighUp one rung as above Where the classifier returns a distribution: more than 0.15 of the probability mass sitting on scores cheaper than the winner Up one rung as above, plus the distribution itself under classifier_detail -
Constrain
no network · capability walk · size floor · sticky loops
What the request needs is read from the body, not guessed: tools when a
toolslist is present andtool_choiceis not"none", or when the history already carries tool traffic; images when any content part is one; structured outputs forresponse_formatof typejson_schemaand JSON mode forjson_object; and a context estimate of one token per four characters plus themax_tokensyou sent, or 4,096 when you sent none. A rung fits when it has every capability and estimated input plus output is at most 0.9 of its context window.Before the walk, the number and the task have already been resolved to one model, in a fixed order: a per-task routing table entry, when one is configured, wins and says so with
table_hit: true; otherwise the roster specialist that declares that task at that rung; otherwise the rung's generalist. The response names the row that won withrole. The walk starts at that model and moves up the ladder, then down — never to a cheaper model first. A specialist sits beside its rung rather than on it, so one that cannot take the request hands it to its own rung's generalist, which then walks as usual: the math specialist has no image input, so a math question with a chart in it is answered by rung 3's generalist withcapability_bump: "images". Where the walk lands is reported ascapability_bump, naming what the assigned model lacked:tools,images,structured outputs,json modeorcontext. When nothing on the ladder fits, the answer is400 no_capable_modelrather than a quietly truncated prompt.A request that continues a tool loop — its last message is a tool result, or an assistant turn carrying
tool_calls— reuses the model that answered the loop's first turn instead of re-scoring every step. The loop is keyed on the caller, the chat id and every message up to the last user turn, and the pin lives 1,800 seconds. If the loop's context has grown past what that model can take, the pin is dropped withreason: "sticky broken: context"; otherwise the response carriessticky: trueandX-TeamShared-Sticky: 1. -
Complete
one upstream call · connect 5 s · read 120 s · HTTP/2
Your original messages go to the chosen model with your bearer swapped for the router's own provider key; your token never leaves this host. Before the first byte, a connect failure or a
408,429,500,502,503or504is retried 1 more time with backoff capped at 2 seconds — an upstreamRetry-Afteris honoured up to the same cap — and then the next model is tried: a specialist falls back to its rung's generalist, a generalist to the adjacent rung — one up, or one down from rung 5. After the first byte nothing is replayed.X-TeamShared-Attemptscounts every try, andX-TeamShared-Assigned-Modelnames the model that actually answered.A non-streamed answer is then checked before it is returned. Five things make it unusable:
Kind Test emptyNo text and no tool calls, with finish_reasonofstoptruncatedfinish_reasonoflengthbelow themax_tokensyou asked forbad_tool_callA call to a function you did not declare, or arguments that are not JSON bad_jsonA json_objectorjson_schemaanswer that does not parse, or misses a required key of the schemarefusalA refusalfield, or a short answer opening I'm sorry, I can't help, As an AIAn unusable answer buys one more completion one rung up for the same task — the next rung's specialist when one claims the task there, else its generalist — or one down when it was already at the top. If the second passes it is returned with
X-TeamShared-Escalatednaming the kind that failed; if it does not, the first answer comes back as it was. A stream is already flushing by the time any of this could be known, so it is returned as it stands — its routing rides on the response headers, andstream_options.include_usageis added so it is metered like everything else.
One scoring hop, then one completion — a second only when the first will not answer or comes back unusable. This is a completions gateway, not an agent loop: nothing here runs tools, keeps memory, or calls a model twice on your behalf beyond that.
The roster
Five rungs, ten models
Each difficulty maps to a generalist that answers well at that level for as little as possible. Five specialists sit beside the ladder, each claiming particular task kinds at particular rungs; a specialist's rung is the same 1 to 5 scale, not a separate one. Because input and output are priced separately, a model is not always cheaper than the one above it on both. Every id is pasteable: send it as the model to pin that model.
- 1trivial: greetings, arithmetic, one-fact lookup
- 2easy rewrite, translation, or short summary
- 3standard coding, SQL, or professional analysis
- 4hard systems, proofs, security review, architecture
- 5expert research, novel protocols, formal methods
| Difficulty | Generalist | Input / 1M | Output / 1M |
|---|---|---|---|
| 1 | mistralai/mistral-nemo | $0.03 | $0.03 |
| 2 | deepseek/deepseek-v4-flash | $0.09 | $0.18 |
| 3 | z-ai/glm-5.3-flash | $0.15 | $0.50 |
| 4 | tencent/hy4-preview | $0.83 | $2.50 |
| 5 | deepseek/deepseek-v4.1-flash-1017 | $0.01 | $0.50 |
| Difficulty | Specialist | Role | Tasks | Basis | Input / 1M | Output / 1M |
|---|---|---|---|---|---|---|
| 1–2 | google/gemini-2.5-flash-lite | linguist | translate, rewrite, lookup | declared | $0.10 | $0.40 |
| 3 | deepseek/deepseek-v4.1-flash | coder | code, sql | declared | $0.05 | $0.57 |
| 3–4 | openai/gpt-oss-120b | mathematician | math | declared | $0.15 | $0.60 |
| 4 | minimax/minimax-m3 | writer | creative, research | declared | $0.30 | $1.20 |
| 4–5 | openai/gpt-5.6-luna | architect | code 4–5, security 4–5, research 5 | declared | $0.20 | $1.20 |
This is gating, not voting: once the score and the task are known exactly one model is asked. A per-task routing table entry wins when one is configured; otherwise a specialist that declares that task at that rung; otherwise the rung's generalist. A specialist at rung 3 answers the tasks it declares when they score 3, and nothing that scores 2 or 4. The classifier never sees this table — it describes the request, and the router picks the model — so editing the roster never requires re-tuning the classifier. The Basis column is the honest part: declared means the role and its strength are a prior written into the roster by hand, not a measurement. scripts/build_labels.py --roster runs a labelled prompt set through all ten models and judges each answer, and scripts/build_quality.py turns the passes into per-task, per-difficulty pass rates that outvote the declaration as they accumulate. The response names the row that answered: router.role and X-TeamShared-Role carry the specialist's role, and are empty and absent when a generalist answered.
The rubric on the left is the one the classifier is scored against, verbatim. Both tables are generated from the ladder and the roster this deployment routes on, not written by hand, and the prices are list, per million tokens, from the model catalog the router ships and refreshes. What you are billed is the cost the upstream reports for your own call, which is the number the ledger records. Neither table is an allowlist: any model the router carries still routes by explicit id whether or not it has a row.
Response
Every answer says how it was routed
A JSON completion carries a router object alongside the usual choices and usage. A stream cannot carry a body field, so the decision rides on response headers too — every field that fits in one has a header, and a few are body-only.
| Body field | Header | Meaning |
|---|---|---|
difficulty | X-TeamShared-Difficulty | 1 to 5, after the gate and the size floor: the rung the roster was looked up at. |
task | X-TeamShared-Task | One of the eleven task kinds. |
assigned_model | X-TeamShared-Assigned-Model | The upstream id that answered. After an availability fallback or an escalation it is the model that actually did, not the one first assigned. |
confidence | X-TeamShared-Confidence | The classifier's 0 to 1 confidence in its score. Absent when the prefilter or a sticky loop assigned the rung. |
risk | X-TeamShared-Risk | low, med or high: what a too-cheap route would cost. |
output | X-TeamShared-Output | Expected answer length: short, medium, long or very_long. |
needs_tools | — | Whether the classifier read the request as wanting tools, browsing or execution. |
prefilter | X-TeamShared-Prefilter | The deterministic rule that fired: empty, health, tool_ping, ack, size-floor or learned. An empty string, and no header, when none did. |
bumped | X-TeamShared-Route-Bumped | 1 when the gate or the size floor raised the rung. |
fallback | X-TeamShared-Route-Fallback | 1 when scoring failed and the request snapped to rung 3. |
sticky | X-TeamShared-Sticky | 1 when a tool loop reused the model that answered its first turn. |
capability_bump | X-TeamShared-Capability-Bump | What the assigned rung lacked when the walk moved the request, comma-separated. |
table_hit | X-TeamShared-Table-Hit | 1 when a per-task routing table chose the model, ahead of the roster. |
role | X-TeamShared-Role | The roster role of the model that answered — coder, mathematician and the rest of the Role column above. Empty, and no header, when a generalist answered, including after a specialist fell back to its rung's generalist. |
classifier | — | Who decided: the classifier's own name, prefilter, or sticky. |
reason | — | The classifier's stated reason, or the rule that fired: ack, health probe, large context, sticky loop, classifier unreachable. |
suggested_model | — | Kept for older classifier output that named a model. Empty today. |
| — | X-TeamShared-Escalated | Present when a non-streamed answer failed the answer check and the retry passed: the kind that failed. |
| — | X-TeamShared-Attempts | Upstream tries, counting retries and fallbacks. 0 for a short-circuit. |
| — | X-TeamShared-Upstream-Model | The upstream's own id for the model that answered; prefilter for a short-circuit. |
| — | Server-Timing | validate, classify and upstream (time to first byte), in milliseconds. |
| — | X-Request-Id, X-Trace-Id, traceparent | Per-request ids. An inbound traceparent is kept, so every step of an agent loop shares one trace. |
Every routed request also writes one structured decision line on the router's side. Your prompt is never written to it verbatim: it is hashed after lowercasing and collapsing whitespace, and the request is otherwise described by counts, lengths and the decision itself. The one field to know about is reason, which is copied from the classifier's own output — a classifier that quotes the prompt back puts that quote in the line.
Try it
Run the prefilter on a prompt
The first stage is deterministic, so it runs here in your browser. The word tables and the size floors it uses are rendered into this page from the module and the settings that route, not copied into the script. The classifier is not simulated: past this stage a real request costs one scoring hop.
System prompt, tool schemas, tool results. The size floors count all of it.
Scripts are off, so nothing runs here. The rules are the first table under the request path.
Coverage
Namespaced ids, one bearer
The first path segment of a model id selects the upstream adapter; the rest is forwarded as that provider's own id. Provider keys stay on the router, so your client holds one token however many providers it reaches. A prefix whose upstream is not configured is left out of GET /v1/models and answers 400 upstream_not_configured.
| Send | What happens |
|---|---|
AutoRouter | Scored and labelled by task, then one model of the roster above: the specialist that declares that task at that score, else the rung's generalist. The id echoed back is AutoRouter; the model that answered is in the headers. |
deepseek/deepseek-v4-flash | Routed to OpenRouter (the openrouter/ prefix is optional). Any OpenRouter id routes, listed or not; the list is a catalog, not an allowlist. |
openrouter/nvidia/nemotron-3-super-120b-a12b:free | A free-tier id. OpenRouter caps :free models at 20 requests a minute and 1,000 a day across the account. |
xai/grok-4 | The xAI adapter, forwarded as grok-4. |
zai/glm-5.3 | The Z.ai adapter, forwarded as glm-5.3. |
fireworks/accounts/fireworks/models/deepseek-v3p1 | The Fireworks adapter, forwarded with its full account path. |
OpenRouter's own model fallbacks pass through: send models as a list of router ids — openrouter/google/gemma-4-31b-it:free then openrouter/deepseek/deepseek-v4-flash, say — and OpenRouter tries them in order when the first rate-limits or is down. The router strips the prefix, drops ids on other prefixes, and drops the list entirely for non-OpenRouter upstreams.
Quickstart
Change the base URL, keep the code
Any OpenAI client works, streaming included. Mint a key in the console, point the SDK at https://router.teamshared.com/v1, and send AutoRouter to let the router pick — or an explicit id to pin one. The routing decision is a header away in every SDK that exposes the raw response.
curl -i https://router.teamshared.com/v1/chat/completions \
-H "Authorization: Bearer $ROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "AutoRouter",
"messages": [{"role": "user", "content": "Rewrite this query as a CTE: SELECT ..."}]
}'
import os
from openai import OpenAI
client = OpenAI(base_url="https://router.teamshared.com/v1", api_key=os.environ["ROUTER_KEY"])
raw = client.chat.completions.with_raw_response.create(
model="AutoRouter",
messages=[{"role": "user", "content": "Rewrite this query as a CTE: SELECT ..."}],
)
print(raw.headers["x-teamshared-assigned-model"]) # the model that answered
completion = raw.parse()
print(completion.choices[0].message.content)
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://router.teamshared.com/v1", apiKey: process.env.ROUTER_KEY });
const { data, response } = await client.chat.completions
.create({
model: "AutoRouter",
messages: [{ role: "user", content: "Rewrite this query as a CTE: SELECT ..." }],
})
.withResponse();
console.log(response.headers.get("x-teamshared-assigned-model")); // the model that answered
console.log(data.choices[0].message.content);
Auth
The bearer is your TeamShared identity: an org API key minted in the console, or the session or Connect token TeamShared already issued you. It is validated against TeamShared before any upstream call, and the check fails closed — a 401 and no upstream request when it cannot be confirmed. Spend is attributed to the org and user that token resolves to, which is why one shared key for everyone is the wrong shape.
Credits
Prepaid. Each completion draws down by the cost the upstream reports for that call, and the console shows the balance plus a per-completion ledger you can export. With credit gating on, a zero balance answers 402 insufficient_credits before any upstream call rather than after one. Top-ups go through hosted Stripe Checkout; a card never touches this host.