Frozen JSON Schemas for the generic routing engine. The engine does pure model
selection (boolean rules over classifiers, first-match-wins, fail-open to
default_model); trust-specific concerns (verdicts, block, audit, consent) are
layered on top via the outputs pass-through bag, request metadata, and the
decision trace — never in the engine.
| Schema | Describes |
|---|---|
route_policy.schema.json |
The routing block embedded in a collection.router collection JSON. Invoked like collection.omni: point the OpenAI model field at the collection name. |
request.schema.json |
The request-side extension fields on the OpenAI chat body: metadata (string-valued routing inputs) and the optional route_trace. Validates only those fields (additionalProperties: true); no version (rides on the stock OpenAI request). |
decision.schema.json |
The x_lemonade_route decision object attached additively to the chat response. |
Both schemas carry a required root version field — these files define
version "1". It is required (not optional) because the format is greenfield:
declaring it from the first policy avoids a breaking "add it later" migration and
lets a server branch cleanly on shape instead of guessing when the field is
absent. A server validates a document against the schema matching its major
version and rejects an unknown major with a clear message rather than a confusing
per-key error. The policy version is author-declared; the decision version is
always emitted by the engine.
Evolution rule:
- Additive, backward-compatible data (new keys in the open
outputsbag or requestmetadata) needs no version bump — those seams are unconstrained. - New vocabulary (a new condition op, classifier
type, or decision field) is a deliberate schema edit; a compatible addition stays within major1, a breaking change ships a new major and a new schema file.
Hard rule: a future server must never break a policy authored against an
earlier major. This holds structurally — old policies keep validating against a
newer (superset) schema, and additionalProperties: false only constrains the
forward direction (an old server rejecting a newer policy), which version
handles cleanly.
Two operating principles keep it true:
- Never redefine a shipped field — only add. A v1 field's meaning is
immutable. New behavior arrives as a new field / op / classifier
type(which old policies don't use), never as a reinterpretation of an existing one. Theclassifier.typeenum already reserves future preset names for this reason. - Never delete or edit a shipped major's schema + parser. Evolution is
additive: ship
vN+1schemas and per-major load-time shims that upgrade older documents to the latest internal form; retain every prior major's schema so its policies still validate and route.
These are enforced mechanically, not just by convention:
schema-lock.json+test/test_schema_lock.pypin a canonical hash of each schema. A released major is immutable — any change fails CI (ship a new major instead). An unreleased major may change, but only with a refreshed lock in the same diff (python test/test_schema_lock.py --update), so every schema edit is a visible, reviewed change rather than silent drift. (v1 staysreleased: falsethrough implementation — the design is signed off, but the schema can still be refined with a reviewed lock refresh until the engine ships. Flip totrueat product release, when real policies exist in the wild and immutability actually protects users.)test/test_routing_fixtures.pykeeps the schemas self-valid and the example fixtures conformant.- A committed conformance corpus (golden policy → expected
Decision) undertest/conformance/routing/<schema_major>/enforces behavioral stability across versions: a runner replays each case through the real engine and asserts the emittedDecisionequals the recorded one, field for field. See that directory'sREADME.md.
Lemonade executes a policy identically regardless of version — the only behavioral drift for model-backed classifiers (semantic_similarity / classifier / llm) comes from the backend engine or the model, not from lemonade. To keep that drift from silently flipping a route, the engine-owned semantics below are frozen for v1 (pinned in the schema field descriptions):
| Semantic | Frozen v1 definition |
|---|---|
keywords_any / keywords_all |
case-insensitive substring over input text (case-insensitivity is an ASCII-only fold, locale-independent); each array is non-empty with non-empty items |
regex |
ECMAScript dialect (std::regex), case-sensitive; pattern must be non-empty; patterns with a nested unbounded quantifier ((X+)+ — catastrophic backtracking) are rejected at policy load |
min_score / max_score |
inclusive band (>= / <=); default min_score: 0.5 when neither bound is given |
min_chars / max_chars |
input length in UTF-8 bytes (not code points) |
min_total_chars / max_total_chars |
length of all text content in the request in UTF-8 bytes — chat messages summed over every role, a legacy prompt (equal to chars), or a Responses input array summed over all items; non-text parts contribute nothing |
min_turns / max_turns |
conversation depth: count of role:"user" turns in messages/input; harness-agnostic (pure message count) |
metadata |
reads a request metadata key; case-sensitive comparison, value decoded into a comma-split, trimmed token set (equals raw exact / any set-intersection / exists presence). gte/lte parse the value as a number instead. A missing, empty, or whitespace-only value counts as absent (matches only exists:false, and never satisfies gte/lte); a non-numeric value likewise never satisfies gte/lte |
| multi-key leaf object | interpreted as implicit all; e.g. {"keywords_any":[...],"max_chars":1000} means both leaves must match |
on_error (omitted) |
default match_false (fail-open) |
routing.router desugaring |
expansion to one llm classifier + identity rules is deterministic and behavior-equivalent across versions |
Anything fancier (token/BM25 keyword matching, a different regex engine, token-based length) ships as a new, separately named op — never by changing one of the above.
Lean local-form examples — components reference already-registered models by
name; the full models[] manifest is only needed for Hugging Face
redistribution. Fixtures live in test/cpp/fixtures/routing/:
| Fixture | Level | Mechanism |
|---|---|---|
l0a_llm_router.json |
L0(a) | routing.router LLM-as-router (desugars to one llm classifier + identity rules) |
l1_keywords.json |
L1 | Deterministic keywords_any / regex / min_chars |
l1_metadata.json |
L1 | Deterministic metadata match on caller-supplied routing inputs (task_class / consent) |
l2_semantic.json |
L2 | semantic_similarity classifier (embeddings + cosine) |
l3_classifier.json |
L3 | Model-backed classifier (PII / jailbreak) |
decision_example.json |
— | A Decision + trace |
Levels compose — one policy may mix a router, classifiers, and deterministic conditions across its rules.
After the engine resolves route_to (matched rule or default_model), it looks
up cost metadata for that candidate via CostServices and, when any field is
present, merges it into the decision as outputs.estimated_cost:
"estimated_cost": {
"cost_tier": "medium",
"cost_input_per_million": 3.0,
"cost_output_per_million": 15.0,
"latency_ms_hint": 40.0
}All keys are optional. This is illustrative, not a billing figure — the same
caveat as the per-million fields on /v1/models.
Where the numbers come from
| Field | Source |
|---|---|
cost_input_per_million / cost_output_per_million |
Typed ModelInfo fields when >= 0 (cloud auto-discovery already populates these); otherwise the same keys in ModelInfo::extras for hand-authored entries |
cost_tier |
extras only ("free" | "low" | "medium" | "high") |
latency_ms_hint |
extras only (local/compute proxy) |
No server_models.json schema change is required: unrecognized keys already land
in extras. Authors can add e.g. "cost_tier": "free" or
"cost_input_per_million": 3.0 on a model entry today.
Phase A is reporting only. Automated cheapest-candidate selection (cost_select
on route_to) is deferred.
The C++ types/interfaces these schemas back live in
src/cpp/include/lemon/routing_policy.h. Validation:
- Schema validation —
python test/test_routing_fixtures.py(usesjsonschema). - Contract / cross-field invariants —
test/cpp/test_routing_policy_contract.cpp(CTest targetRoutingPolicyContractTest): default_model and everyroute_tomust be a candidate; classifier condition refs must resolve. - Behavioral back-compat —
test/cpp/test_routing_conformance_corpus.cpp(CTest targetRoutingConformanceCorpusTest): replays the golden corpus undertest/conformance/routing/and asserts each emittedDecisionequals its recorded expectation.