September 25, 2026
Evaluate model routing on three separate tracks: answer quality, billed session cost after caching, and routing latency. GMI Router returns selection metadata for attribution, while fixed-model baselines and per-arm API keys share GMI Cloud's billing views.
GMI Router selects a model per request, with no judge model and a published routing time under 200 ms. The plan below covers measurements, sample sizes, billing, and latency attribution.
A composite score hides task-level quality differences, uses the wrong cost unit for cached sessions, and mixes model speed with routing overhead.
Report quality per task bucket: a router can improve reasoning while weakening simple rewrites, leaving the overall mean unchanged.
Report cost per completed session. Per-call pricing misses cache reuse across a multi-turn conversation.
A 90 ms end-to-end delay may come from routing or selection of a slower model. Matched-model comparisons separate those effects.
| Question | Unit of measurement | Instrument | Acceptance threshold to set in advance |
|---|---|---|---|
| Is the answer quality acceptable? | Win rate per task bucket, in paired comparisons against a fixed-model baseline | Blind pairwise grading of routed vs baseline output | Routed arm at or above baseline in every shipped bucket |
| What does a session cost after caching? | Dollars per completed session | Billing records in the GMI Cloud Console, filtered by API key | Target reduction vs the baseline arm, decided before the run |
| How much delay does routing add? | p50 and p95 delta on matched-model requests | Time to first token, routed arm vs fixed arm | p95 delta within your latency budget |
Use GMI Router for the routed arm, MaaS endpoints for fixed baselines, the Console playground for smoke tests, and Console usage for billing. Keep the arms under the same accounting rules.
| Piece of the rig | Service | What it provides for the test |
|---|---|---|
| The routed arm | GMI Router, through the dedicated routing endpoint | Per-request model selection, plus routing metadata with the model actually chosen |
| The baseline arms | GMI Cloud MaaS serverless model endpoints | Fixed-model control groups on the same OpenAI-compatible API design, so the request path is the only thing that differs |
| The smoke test | GMI Router playground in the Console | Hand-checks a prompt and shows the routing decision before you spend an afternoon on harness bugs |
| The cost ledger | Console billing usage views | Spend split by API key, model, and time window, which is the only authoritative cost number |
The routed arm calls https://console.gmicloud.ai/api/v1/ie/recommendation/autoroute with a bearer key, messages, and optional cost/balanced/quality mode. Fixed arms use POST /v1/chat/completions on https://api.gmi-serving.com/v1. See the GMI Router documentation and GMI Cloud MaaS page.
Smoke-test five representative prompts in the playground. Routing is free for a limited preview period, so the cost comparison currently measures model spend.
Use blind paired comparisons within each task bucket. Apply five controls:
1. Define buckets from actual product tasks, including deliberately easy and hard cases. GMI's leaderboard categories offer a starting taxonomy: Agentic, Coding, Instruction, Math, Reasoning, Knowledge, Language, and Data-Analysis. Its benchmark chart includes GPQA Diamond, IFBench, LiveBench, LiveCodeBench, Terminal-Bench, and WildBench; use your own prompts rather than copying benchmark content.
2. For each prompt, grade routed and baseline answers head to head as win, loss, or tie, avoiding an absolute score that varies across graders.
3. Blind the grader: randomize A/B order and remove model names and routing metadata. Use a judge outside both arms' model pools, and score each pair twice with the sides swapped to control position bias.
4. Use two fixed baselines: the frontier model otherwise chosen for production, such as openai/gpt-5.5, and a cheaper candidate. Compare routed cost with the frontier and routed quality with the cheap baseline.
5. Send byte-identical messages. Router documents messages, mode, and stream; unknown fields are ignored. Leave baseline sampling at endpoint defaults and disclose that sampling is uncontrolled across interfaces rather than claiming it is matched.
GMI reports a +2.4-point Quality-mode improvement over GPT-5.5 in its internal benchmark across eight task categories. This is directional, not independently audited, and limited to the allowed pool. Results vary with workload, prompt length, pool, and settings; measure your own gain. (GMI Router product page)
Choose the smallest win-rate difference that would change the deployment decision, then size each bucket. The examples below require 79–1,305 decisive comparisons.
The counts use a two-sided normal approximation to a binomial test against a 50% win rate, with 5% significance and 80% power. Exclude ties from decisive comparisons.
| True win rate you want to detect | Decisive comparisons needed per bucket | Total prompts if 40 percent of pairs are ties |
|---|---|---|
| 55 percent | 783 | 1,305 |
| 60 percent | 194 | 324 |
| 65 percent | 85 | 142 |
| 70 percent | 47 | 79 |
A 55% target across six buckets needs roughly 7,800 graded pairs. If that is unaffordable, prioritize three high-traffic buckets or a larger effect: detecting 65% needs 142 prompts. Explicitly assign unmeasured buckets to routing or a fixed endpoint until evaluated.
For 400 prompts per arm at 3,000 input and 600 output tokens, GPT-5.5 list rates of $5/$30 per 1M tokens yield $6.00 input + $7.20 output = $13.20 per arm, or about $40 for three arms at those illustrative rates. Rates are from October 15, 2026; recheck the model library.
Two swapped-side grades per pair require 800 judge calls. At 4,200 input and 100 output tokens each, the same illustrative rates give $16.80 + $2.40, about $19 per baseline comparison. Two baselines add about $38 in grading to $40 in generation: roughly $78 total. The judge must sit outside both arms' pools; budget its actual catalog rate.
Use settled Console billing filtered by an API key dedicated to each arm, divided by completed sessions.
The usage object reports prompt_tokens, completion_tokens, and total_tokens, not cache-priced tokens. On October 15, 2026, GPT-5.5 cache reads listed at $0.50 per 1M tokens versus $5.00 for fresh input. Token counts times list rates miss that tenfold difference; report the settled bill. (model library; billing usage views)
Keep the billing split reproducible:
Inspect routed spend by model. If one model receives 90% of spend, the breakdown also reveals how the pool fits your task mix.
Measure p50 and p95 TTFT deltas on pairs where routed selected_model equals the fixed baseline. Log request-sent, headers-received, and first-token timestamps; TTFT is first token minus send time. With fewer than about 20 matched pairs, p95 reflects only the largest observations.
Log selected_model, task_type, selected_mode, fallback_models, and recommendation_id from routing_metadata. Non-streaming returns an object; streaming emits metadata before [DONE]. Capture X-Recommendation-ID at header arrival where available; it is absent on auth and early validation errors and may still help attribute a failed stream.
Compute routed-minus-baseline TTFT for identical prompts on the same serving model. This estimates routed-path overhead; unmatched-model latency also reflects model selection and belongs in the broader product evaluation.
Apply three latency controls:
GMI publishes routing time under 200 ms before model execution. Your matched-model delta also includes network, queue, and cache differences; report a distribution. For a substantially larger delta, inspect connections and fallback rows before blaming routing. GMI plans fuller production metrics, but deployment should use your own measured latency budget. (GMI Router page)
The harness streams both endpoint shapes, records routing metadata and TTFT, retains partial output, bounds 429 retries, distinguishes empty from incomplete responses, and validates prompts before sending them.
import codecs, json, os, time, uuid
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
ROUTER_URL = "https://console.gmicloud.ai/api/v1/ie/recommendation/autoroute"
MAAS_URL = "https://api.gmi-serving.com/v1/chat/completions"
CONNECT_TIMEOUT, READ_TIMEOUT = 10, 120 # connect and idle read, not a wall-clock deadline
MAX_RETRIES = 2
RETRY_BUDGET_S = 30 # total wait allowed across retries, not per attempt
META_FIELDS = ("selected_model", "task_type", "selected_mode", "fallback_models")
def _validate(messages):
if not isinstance(messages, list) or not messages:
raise ValueError("messages must be a non-empty list")
has_user = False
for m in messages:
if not isinstance(m, dict):
raise ValueError("each message must be a dict")
content = m.get("content")
if not isinstance(content, str) or not content.strip():
raise ValueError("each message needs non-empty string content")
has_user = has_user or m.get("role") == "user"
if not has_user:
raise ValueError("at least one user message is required")
def _payload(arm, messages):
_validate(messages) # raises before any network call
body = {"messages": messages, "stream": True}
if arm["kind"] == "router":
body["mode"] = arm["mode"] # cost | balanced | quality
else:
body["model"] = arm["model"] # baseline stays on endpoint defaults
return body
def _retry_delay(header, attempt):
"""Retry-After is delay-seconds or an HTTP-date. Anything else falls back to backoff."""
fallback = float(2 ** attempt)
if not header:
return fallback
try:
return max(0.0, float(header))
except (TypeError, ValueError):
pass
try:
when = parsedate_to_datetime(header)
except (TypeError, ValueError, IndexError):
return fallback
if when is None:
return fallback
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
def _sse_lines(resp):
"""Yield SSE lines as they arrive. iter_lines buffers and would inflate TTFT.
Decode UTF-8 here rather than passing decode_unicode=True: Requests falls back
to ISO-8859-1 when a text response omits a charset, which mangles non-ASCII
answers, and an incremental decoder survives a character split across chunks."""
decode = codecs.getincrementaldecoder("utf-8")("replace").decode
buf = ""
for chunk in resp.iter_content(chunk_size=None):
if not chunk:
continue
buf += decode(chunk)
while "\n" in buf:
line, buf = buf.split("\n", 1)
yield line.rstrip("\r")
buf += decode(b"", True)
if buf:
yield buf
def _read_stream(resp, t0, state):
"""Writes into state as it goes, so a mid-stream failure keeps what already arrived."""
event, parts = None, []
for line in _sse_lines(resp):
if not line:
event = None
continue
if line.startswith("event:"):
event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
state["closed"] = True
break
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue # keep-alive or other non-JSON event
if not isinstance(obj, dict):
continue # null or scalar event payload
if event == "error" or "error" in obj:
state["stream_error"] = str(obj.get("error", obj))[:200]
continue
if event == "routing_metadata" or "routing_metadata" in obj:
payload = obj.get("routing_metadata", obj)
if isinstance(payload, dict):
state["meta"] = payload # handles both the nested and the flat event shape
continue
for choice in obj.get("choices") or []:
piece = ((choice or {}).get("delta") or {}).get("content") or ""
if piece:
if state["ttft_ms"] is None:
state["ttft_ms"] = (time.perf_counter() - t0) * 1000
parts.append(piece)
state["answer"] = "".join(parts)
def _is_timeout(exc):
"""A read timeout mid-stream reaches you as ConnectionError wrapping urllib3's
error, sometimes only through args rather than __cause__."""
level, depth = [exc], 0
while level and depth < 6:
nxt = []
for err in level:
if not isinstance(err, BaseException):
continue
if isinstance(err, requests.Timeout) or "Timeout" in type(err).__name__:
return True
nxt += [err.__cause__, err.__context__, *getattr(err, "args", ())]
level, depth = nxt, depth + 1
return False
def _finish(row, state, t0, t_start, status, error):
now = time.perf_counter()
row["attempt_ms"] = (now - t0) * 1000 # last attempt only
row["total_ms"] = (now - t_start) * 1000 # includes earlier attempts and backoff
row["ttft_ms"] = state["ttft_ms"]
row["answer"] = state["answer"] # partial output is kept, never discarded
for field in META_FIELDS: # keep the whole decision, not just the model
value = state["meta"].get(field)
if value is not None: # an empty fallback list is a real answer
row[field] = value
row["recommendation_id"] = row["recommendation_id"] or state["meta"].get("recommendation_id")
row["status"], row["error"] = status, error
return row
def call_once(arm, messages, session_id, turn):
row = {"request_id": str(uuid.uuid4()), "arm": arm["name"], "session_id": session_id,
"turn": turn, "attempts": 0, "status": "error", "ttft_ms": None,
"attempt_ms": None, "total_ms": None, "selected_model": arm.get("model"),
"task_type": None, "selected_mode": None, "fallback_models": None,
"recommendation_id": None, "answer": "", "error": None}
url = ROUTER_URL if arm["kind"] == "router" else MAAS_URL
body = _payload(arm, messages) # raises on invalid input, before the network
headers = {"Authorization": f"Bearer {os.environ[arm['key_env']]}", # one key per arm
"Content-Type": "application/json"}
t_start, waited = time.perf_counter(), 0.0
for attempt in range(MAX_RETRIES + 1):
row["attempts"] = attempt + 1
state = {"ttft_ms": None, "answer": "", "meta": {},
"closed": False, "stream_error": None}
t0 = time.perf_counter()
try:
with requests.post(url, json=body, headers=headers, stream=True,
timeout=(CONNECT_TIMEOUT, READ_TIMEOUT)) as resp:
row["recommendation_id"] = resp.headers.get("X-Recommendation-ID")
if resp.status_code == 429:
if attempt == MAX_RETRIES:
return _finish(row, state, t0, t_start, "rate_limited",
"429 after retries")
wait = _retry_delay(resp.headers.get("Retry-After"), attempt)
if waited + wait > RETRY_BUDGET_S: # budget spans every retry
return _finish(row, state, t0, t_start, "rate_limited",
f"{waited + wait:.0f}s of waiting over budget")
resp.close()
time.sleep(wait)
waited += wait
continue
if resp.status_code >= 400:
return _finish(row, state, t0, t_start, "http_error",
f"{resp.status_code}: {resp.text[:200]}")
_read_stream(resp, t0, state)
except requests.RequestException as exc:
kind = "timeout" if _is_timeout(exc) else "transport_error"
return _finish(row, state, t0, t_start, kind, str(exc)[:200])
if state["stream_error"]:
return _finish(row, state, t0, t_start, "partial", state["stream_error"])
if not state["closed"]:
return _finish(row, state, t0, t_start, "incomplete", "stream ended without [DONE]")
return _finish(row, state, t0, t_start,
"ok" if state["answer"].strip() else "empty", None)
return row
def percentile(values, pct):
clean = sorted(v for v in values if v is not None)
if not clean:
return None # no comparable requests, say so
k = (len(clean) - 1) * pct / 100
lo, hi = int(k), min(int(k) + 1, len(clean) - 1)
return clean[lo] + (clean[hi] - clean[lo]) * (k - lo)
The implementation makes six checks explicit:
ok rows go into the quality and latency stats. empty, partial, incomplete, timeout, rate_limited and http_error are separate statuses, so a truncated answer never scores as a complete one.total_ms spans every attempt and the backoff between them, while attempt_ms covers the last one. Averaging a field that silently drops retried time is how a rate-limited run ends up looking fast.task_type and selected_mode are what let you check later that a bucket was classified the way you assumed, and fallback_models tells you what the router would have reached for.percentile returns None on an empty list instead of raising or reporting 0 ms. That is what happens when the matched-model filter leaves you with nothing comparable, and you want to see it before it reaches a chart.Replay each session in turn order, alternate arms, and append results to JSONL keyed by session_id and turn. Log model, bucket, arm, and retries there; obtain dollar cost from Console billing.
Set per-bucket thresholds before testing: at least 50% wins in decisive comparisons, billed session cost below the frontier baseline by the required margin, and matched-model p95 within the product latency budget. Use GMI's published sub-200 ms routing time as context, not a substitute for measurement.
| Track | Metric | Ship routing for that bucket when | What to do otherwise |
|---|---|---|---|
| Quality | Routed win rate in decisive paired comparisons | At or above 50 percent, with the bucket sized for the effect you set out to detect | Send that bucket straight to a GMI Cloud MaaS serverless endpoint and keep GMI Router on for the rest |
| Cost | Billed dollars per completed session | Materially below the frontier baseline, with the target set in advance | Re-run in cost mode and compare again before changing anything else |
| Latency | p95 time to first token on matched-model pairs | Delta sits inside your product's latency budget | Read the fallback and error rows first, since a tail inflated by retried requests is measuring your transport conditions rather than the routing decision |
Act per bucket: mode travels with each request from the client; the organization owner sets the workspace-wide Allowed Model Pool. These are separate controls, so request policy and catalog governance need separate owners.
Check quality first. A bucket that fails its quality threshold does not qualify on cost savings alone.
Re-run the three highest-traffic buckets monthly using stored prompts and fixed baselines. Model catalog changes can alter selections; retain recommendation IDs and metadata to audit older runs.
If one bucket repeatedly selects the same model at predictable load, evaluate Prime Inference: reserved single-tenant GPUs keep weights pre-loaded and bill per GPU-hour. Routing and dedicated serving can share the same account. (GMI Cloud Prime Inference)
Can I evaluate routing quality without building a harness? Yes, for a smoke test. Use the Console playground to check task classification; use a scripted harness for defensible paired comparisons and sample counts.
Why can't I just multiply tokens by list price to get session cost? The usage object does not attribute cache-priced tokens. Multiplying prompt_tokens and completion_tokens by list rates misses cache savings. Use settled Console billing by arm key and model.
How do I know which model answered a routed request? Use selected_model in routing_metadata. Non-streaming includes metadata in the response; streaming emits it before [DONE]. Log the remaining routing fields and X-Recommendation-ID when present. Mark missing-model rows unattributed rather than assigning them to a baseline.
What does the routing step itself cost? Routing and recommendation are free for a limited preview period; pay the selected model's token charges. Recheck the commercial terms before a production commitment.
How long should a routing evaluation take? Allow roughly a week: two days for buckets and the harness, one for interleaved runs, two for grading, and one for billing reconciliation. Set sample sizes before starting; the illustrated range is 79–1,305 decisive comparisons per bucket.
Does routing work for multi-turn sessions, or only single prompts? Test complete sessions in turn order. GMI Router is cache-aware but stateless, so the client must resend the full conversation on every request.
Create one API key per arm, smoke-test five prompts in the playground, then run /autoroute against two fixed-model baselines. GMI's Router launch article and Dynamic Model Escalation for Coding Agents provide background on selection and its two-harness experiment. (GMI Router playground; Automatic Model Selection for Every Prompt: GMI Router Is Live)
For help sizing task buckets or the evaluation budget, contact GMI Cloud with your prompt mix and current model spend. (contact the GMI Cloud team)
Colin Mo
GMI Cloud helps you architect, deploy, optimize, and scale your AI strategies
