create_classifier
Python · package toolnexus · SPEC §8B · python/src/toolnexus/classifier.py
def create_classifier( *, style: ClassifierStyle = "systemone", # "systemone" | "llm" | "custom" | "static" base_url: str | None = None, # "https://api.typesafe.ai/v1" model: str | None = None, # "jev-latest" api_key_env: str | None = None, # "TYPESAFE_API_KEY" — the NAME, never the value headers: Mapping[str, str] | None = None, timeout: float | None = None, # seconds; 10.0 http_transport: ClassifierTransport | None = None, retries: int = 2, retryable_statuses: Iterable[int] | None = None, on_error: ErrorClassifier | None = None, request_params: Mapping[str, Any] | None = None, body_transform: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None, on_metric: OnMetric | None = None, client: Client | None = None, # style="llm" only evaluate: Callable[..., Any] | None = None, # style="custom" only decisions: list[RecordedDecision] | None = None, # style="static" only) -> Classifier
async def Classifier.evaluate(state: Any, questions: Mapping[str, Question]) -> DecisionA sibling of the client: pre-declared typed questions in, calibrated answers out — no messages,
no tool calling, no loop. Tool is the contract for an action; Classifier is the contract
for a judgment. A classifier has no messages, no streaming and no tool calling, so it never
enters the §8 client loop and is never selected as a model for run/ask.
In Python evaluate is a coroutine — await judge.evaluate(state, questions) — unlike the
Go and Java ports, whose calls are plain blocking methods. Construction is synchronous and makes
no request: create_classifier applies the §8B defaults and rejects a style whose required
option is missing before any call happens.
When to use it
Section titled “When to use it”- You need a judgment, not an action — is this ticket asking for a refund, which desk owns it, how urgent is it — and you want a typed number back rather than prose you then parse.
- You want many questions in one round trip over one state ingest. Questions are independent: one answer is never context for another.
- You want a threshold you can tune: a calibrated probability is a number you can compare against a cutoff, which free text is not.
- You want the judgment out of the loop — a classifier makes no tool call, so it cannot act on what it concluded.
Why this and not the alternative
Section titled “Why this and not the alternative”A host that constructs no Classifier observes byte-identical behaviour to a build without §8B,
and constructing one alters no request the client loop makes. This is proven by a test, not
asserted.
Options
Section titled “Options”ClassifierOptions mirrors §8 ClientOptions field-for-field wherever a field makes sense, so a
host that has configured one has configured the other.
| option | default | what it does |
|---|---|---|
style |
"systemone" |
"systemone", "llm", "custom" or "static". Same slot as the client’s style; the values differ because the wires differ. See Backends. |
base_url |
"https://api.typesafe.ai/v1" |
The System One endpoint base. OpenRouter (https://openrouter.ai/api/v1) serves the same wire; self-hosted and open-weights implementations speak it too. |
model |
"jev-latest" |
A floating alias. Pin it (e.g. jev-1.13.0) once thresholds are tuned — Decision.model echoes what actually answered, which may be more specific. |
api_key_env |
"TYPESAFE_API_KEY" |
The name of an environment variable, not the value. Read at call time, never logged. §8’s api_key takes a value; this option deliberately does not. |
headers |
— | Extra request headers. Values expand ${ENV_VAR} from the environment at call time and are never logged, identically to remote-MCP headers (§2). |
timeout |
10.0 (seconds) |
Bounds one request, not a run — a classifier has no loop to bound. |
http_transport |
stdlib urllib |
The §8 Gap 2 injectable transport, scoped to the classifier path only. Takes bytes, because the body is the canonical form this module produced and re-serialising it would throw those bytes away. (§8B calls this slot httpClient/transport.) |
retries |
2 |
Attempts after the first. <= 0 falls back to 2. Backoff is exponential from retry_base_ms, no jitter; a Retry-After header still wins. |
retry_base_ms |
500 |
Base of that backoff, in ms: the delay is base * 2 ** attempt. <= 0 falls back to 500. The classifier’s half of the §8 retry_base_ms it mirrors — set it to 1 to take a retry test off the clock. |
retryable_statuses |
— | Extra HTTP statuses to treat as retryable, added to the default set — it can only widen, never remove. on_error still runs per attempt and has the final say. |
on_error |
retry when retryable | The §8 ErrorInfo -> "retry" | "fail" classifier, reused verbatim. There is no second retry policy and no "suspend" tier here. |
request_params |
— | Merged into the base body before marshalling; a request_params key wins on collision. |
body_transform |
— | Runs after the merge, last before marshalling. Order is base body → request_params → body_transform → marshal. How a gateway’s wrapper or extra fields land without a proxy. |
on_metric |
— | Receives classifier.evaluate events (latency, tokens, model, status) in the same §8 sink, plus classifier.warning for degenerate criteria — whose text is in warning, never error. |
client |
— | style="llm" only: the §8 Client to emulate over. Missing ⇒ ClassifierError at construction. |
evaluate |
— | style="custom" only: your own function (sync or async). Every wire option is ignored. Missing ⇒ ClassifierError at construction. |
decisions |
— | style="static" only: a list of RecordedDecision(state, questions, response), keyed by the canonical request and the state. Named in §8B and gated at core tier by the options manifest — it is the backend CI runs on, so a port without it cannot run the shared fixtures. |
Retries and Retry-After
Section titled “Retries and Retry-After”The default retryable set is 408, 429, 500, 502, 503, 504, 529, plus every network
fault. 408 is the classifier’s addition over the §8 client set — a request timeout on a single
short POST is worth one more attempt. retryable_statuses adds to that set and can never
remove from it (a host cannot drop 429 and lose Retry-After handling with it); on_error
still decides each attempt, so returning "fail" overrides a status listed there. A
Retry-After header is honoured by the §8 delay-seconds rule and takes precedence over the
exponential backoff.
Secrets
Section titled “Secrets”The credential is read from the named environment variable at call time and ${ENV_VAR}
header references expand at call time. No credential value and no expanded header value appears
in any log, metric, error message or returned value; a 401/403 body is never echoed back,
because a gateway happily reflects a bad Authorization header into its own error text. An
authentication failure names the status and the endpoint and nothing else.
Examples
Section titled “Examples”1. The smallest useful call — one question, recorded, offline
Section titled “1. The smallest useful call — one question, recorded, offline”The static backend replays a recorded response keyed by the canonical request plus the state.
It is what CI runs: no network, no credential. It is not a convenience — the live backend is
non-deterministic, so it is the only backend a test may assert a number against.
import asyncio
from toolnexus import NoulQuestion, RecordedDecision, create_classifier
TICKET = "My card was charged twice and the second charge has not been refunded."QUESTIONS = {"wants_money_back": NoulQuestion("Is the customer asking for money to be returned?")}
RECORDED = RecordedDecision( state=TICKET, questions=QUESTIONS, response={ "model": "typesafe/jev-1.13-20260917", "answers": {"wants_money_back": {"type": "noul", "noul": 0.99}}, "usage": {"input_tokens": 118, "output_tokens": 6}, },)
async def main(): judge = create_classifier(style="static", model="typesafe/jev-1.13", decisions=[RECORDED]) decision = await judge.evaluate(TICKET, QUESTIONS)
answer = decision.noul("wants_money_back") assert answer.noul == 0.99 # a noul carries NO confidence — the number IS the answer assert decision.calibrated is True assert decision.model == "typesafe/jev-1.13-20260917" # more specific than what we asked for
print("ok: wants_money_back =", answer.noul, "| model:", decision.model)
asyncio.run(main())2. The realistic case — custom, so a rules engine or a cache is the same seam
Section titled “2. The realistic case — custom, so a rules engine or a cache is the same seam”style="custom" hands evaluate the state and questions and takes your Decision back. Every
wire option is ignored. The function may be sync or async — this one is sync, and the classifier
awaits it only if it returns an awaitable. A cache in front of the live backend, a fine-tuned
local encoder and a hand-written rules engine are all this shape.
import asyncio
from toolnexus import ( ChoiceAnswer, ChoiceQuestion, ClassifierUsage, Decision, NoulAnswer, NoulQuestion, create_classifier,)
QUESTIONS = { "wants_money_back": NoulQuestion("Is the customer asking for money to be returned?"), "department": ChoiceQuestion( "Which desk should own this ticket?", { "billing": "own it here when the problem is money that moved: a duplicate charge, a refund owed", "technical": "own it here when the problem is the product itself: a login that fails, a feature that errors", }, ),}
def rules_engine(state, questions): """A deterministic stand-in. `calibrated=False` because nothing here derived a probability from token probabilities — say so, so no threshold is carried over.""" money = "charged" in state or "refund" in state return Decision( model="rules-engine-v1", answers={ "wants_money_back": NoulAnswer(noul=1.0 if money else 0.0), "department": ChoiceAnswer( choice="billing" if money else "technical", probabilities={"billing": 1.0 if money else 0.0, "technical": 0.0 if money else 1.0}, confidence=1.0, ), }, usage=ClassifierUsage(), calibrated=False, )
async def main(): judge = create_classifier(style="custom", evaluate=rules_engine) decision = await judge.evaluate("My card was charged twice.", QUESTIONS)
assert decision.choice("department").choice == "billing" assert decision.calibrated is False # thresholds tuned on another backend do NOT transfer
print("ok: department =", decision.choice("department").choice, "| calibrated:", decision.calibrated)
asyncio.run(main())3. The full surface — the real wire, with the transport injected
Section titled “3. The full surface — the real wire, with the transport injected”http_transport is the §8 Gap 2 seam. Injecting it exercises the actual systemone path — the
canonical body, request_params, body_transform, the retry budget, the metric sink — with no
socket and no credential. Note what the transport receives: the canonical bytes, with keys
sorted recursively in ASCII order, arrays never reordered, and state verbatim.
import asyncioimport json
from toolnexus import ( ClassifierResponse, NoulQuestion, ScoreQuestion, create_classifier,)
SEEN = {}
class RecordingTransport: """Whatever you pass here must expose one `post(url, headers, body, timeout)`."""
def post(self, url, headers, body, timeout): SEEN["url"] = url SEEN["body"] = json.loads(body) SEEN["auth"] = "Authorization" in headers SEEN["tenant"] = headers.get("X-Tenant") payload = { "model": "jev-1.13.0", "answers": { "resolved": {"type": "noul", "noul": 0.12}, "urgency": { "type": "score", "score": 1.4, "legend": {"0": "can wait", "1": "chase today", "2": "blocked now"}, "probabilities": {"0": 0.1, "1": 0.5, "2": 0.4}, "confidence": 0.61, }, }, "usage": {"input_tokens": 210, "output_tokens": 30, "cost": 0.0000118}, } return ClassifierResponse(status=200, body=json.dumps(payload).encode("utf-8"))
EVENTS = []
QUESTIONS = { "urgency": ScoreQuestion( "How fast does this ticket need a human?", ["can wait", "chase today", "blocked now"], # ORDER IS THE NUMBERING — never sorted ), "resolved": NoulQuestion("Has the customer's problem already been solved?"),}
async def main(): judge = create_classifier( base_url="https://api.typesafe.ai/v1", model="jev-1.13.0", # pinned, not the floating "jev-latest" api_key_env="TYPESAFE_API_KEY", # the NAME of an env var, never the value headers={"X-Tenant": "${TOOLNEXUS_DOCS_TENANT}"}, # expands at call time; unset ⇒ "" timeout=5.0, retries=3, retryable_statuses=[520, 521], # ADDS to {408,429,500,502,503,504,529} request_params={"trace": "docs"}, # merged in; a collision here WINS body_transform=lambda b: {**b, "tier": "batch"}, # last before marshalling on_metric=EVENTS.append, http_transport=RecordingTransport(), )
decision = await judge.evaluate({"ticket": 4021, "text": "still waiting"}, QUESTIONS)
assert SEEN["url"] == "https://api.typesafe.ai/v1/systemone" assert list(SEEN["body"]) == ["model", "questions", "state", "tier", "trace"] # ASCII order assert SEEN["body"]["questions"]["urgency"]["criteria"] == ["can wait", "chase today", "blocked now"] assert SEEN["body"]["state"] == {"ticket": 4021, "text": "still waiting"} # verbatim assert SEEN["tenant"] == "" # an unset ${ENV_VAR} expands to empty, never leaks assert decision.score("urgency").score == 1.4 # a score MAY fall between levels assert decision.usage.cost == 0.0000118 assert EVENTS[-1]["event"] == "classifier.evaluate" and EVENTS[-1]["status"] == "ok"
print("ok: urgency =", decision.score("urgency").score, "| events:", len(EVENTS))
asyncio.run(main())Backends in one line each
Section titled “Backends in one line each”| style | what it is |
|---|---|
systemone |
One POST {base_url}/systemone with the canonical body. Chunking under the token budget and the 255-option cap is the backend’s business and invisible to you. Reports calibrated=True. |
llm |
The three question types rendered as one structured-output call on any §8 Client. This is what makes the seam vendor-neutral — no System One credential needed. Reports calibrated=False. |
custom |
Your own evaluate: a fine-tuned encoder, a rules engine, or a cache in front of either. |
static |
Recorded decisions keyed by the canonical request plus the state. This is what CI runs. A miss raises rather than guessing. |
The full comparison, with the measured latency and cost figures, is on Backends.
See also
Section titled “See also”NoulQuestion— The three question types, the criteria each one needs, and the limits enforced client-side before the request.Decision— One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero.- Typed decisions — why a judgment is a different contract from an action.
- The encoding obligation — the measured reason option descriptions decide whether a
choiceworks at all. - Cookbook: a classifier in the loop — the end-to-end recipe.