batchwatch

batchwatch › Measurement routes

Measurement routes

Seven routes answer questions about queue time. Three are open to anyone (/v1/wait, /v1/curve, /v1/coverage); four are gated behind a contribution or the free trial (/v1/should-i-batch, /v1/estimate-batchtime, /v1/conditions, /v1/distribution).

Seven routes answer questions about queue time. Three are open to anyone (/v1/wait, /v1/curve, /v1/coverage); four are gated behind a contribution or the free trial (/v1/should-i-batch, /v1/estimate-batchtime, /v1/conditions, /v1/distribution).

All of them are GET. All of them are affected by the tier delay except /v1/coverage. See tiers.md.

Read interpreting.md before you act on any number here.


GET /v1/wait

"What is this queue doing?" The simplest route, open without a key.

Parameters

NameTypeRequiredDefaultNotes
providerstringnoopenaiNot validated against a list on this route. An unknown provider simply has no measurements.
modelstringyesMissing gives 400 {"error":"model is required"}.
modestringnobatchbatch or sync. Anything else matches nothing.

Behaviour

Example

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC, no key:

curl 'https://batchwatch.dev/v1/wait?provider=openai&model=gpt-5-nano'
{
  "provider": "openai",
  "model": "gpt-5-nano",
  "mode": "batch",
  "live": false,
  "delayed_by_s": 900,
  "based_on": {
    "n": 1,
    "window": "30d",
    "note": "Too few completions in the last hour; showing the 30-day picture."
  },
  "coverage_30d": { "n": 1, "contributors": 1 },
  "confidence": {
    "level": "very_low",
    "score": 0.13,
    "basis": "exact_model",
    "n": 1,
    "contributors": 1,
    "newest_age_s": 1497,
    "why": "Limited by only 1 measurements, a single contributor, an upper bound we cannot pin down."
  },
  "robustness": {
    "method": "pooled",
    "contributors_voting": 0,
    "note": "Pooled across all measurements: fewer than 3 contributors have enough data to vote, so a single contributor could move this number. Confidence is capped accordingly."
  },
  "basis": "exact_model",
  "freshness": {
    "last_measurement_at": "2026-08-25T13:39:43.000Z",
    "last_measurement_age_s": 1497,
    "last_measurement_age": "25 min",
    "oldest_measurement_at": "2026-08-25T13:39:43.000Z",
    "stale": false
  },
  "measurement": "observed completions",
  "not": "a prediction for your job",
  "plan_for_s": 86400,
  "plan_basis": "sla_prior",
  "p50_s": 1144,
  "p90_s": 1144,
  "p95_s": 1144,
  "vs_normal": 1,
  "hint": "Contribute measurements for live data and the full API."
}

That answer is honest about being thin: one measurement, one contributor, so plan_for_s falls back to OpenAI's published 24-hour window even though the one observed job took 1144s.


GET /v1/curve

The empirical distribution: "what share of jobs had finished after X seconds?" Open without a key, because the model picker on the front page calls it on every click.

Parameters

NameTypeRequiredDefault
providerstringnoopenai
modelstringyes
modestringnobatch

Behaviour

Example — below the hard limit (one measurement)

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC, no key:

curl 'https://batchwatch.dev/v1/curve?provider=openai&model=gpt-5-nano'
{
  "provider": "openai",
  "model": "gpt-5-nano",
  "mode": "batch",
  "live": false,
  "delayed_by_s": 900,
  "n": 1,
  "contributors": 1,
  "window": "30d",
  "precomputed_at": "2026-08-25T14:00:44.000Z",
  "freshness": {
    "last_measurement_at": "2026-08-25T13:39:43.000Z",
    "last_measurement_age_s": 1501,
    "last_measurement_age": "25 min",
    "stale": false
  },
  "measurement": "observed completions",
  "not": "a prediction for your job",
  "drawable": false,
  "points": [],
  "why": "1 measurement - a curve needs at least two points to connect.",
  "confidence": {
    "level": "very_low",
    "score": 0.13,
    "basis": "exact_model",
    "n": 1,
    "contributors": 1,
    "newest_age_s": 1501,
    "why": "Limited by only 1 measurements, a single contributor, an upper bound we cannot pin down."
  }
}

Example — drawable

Captured from a local wrangler dev instance seeded with 24 synthetic measurements from 3 keys. The durations below were made up to show the shape; they are not measured queue times.

{
  "provider": "openai",
  "model": "gpt-5-mini-docs",
  "mode": "batch",
  "live": false,
  "delayed_by_s": 900,
  "n": 24,
  "contributors": 3,
  "window": "30d",
  "freshness": {
    "last_measurement_at": "2026-08-24T14:27:04.000Z",
    "last_measurement_age_s": 85739,
    "last_measurement_age": "24 hours",
    "stale": false
  },
  "measurement": "observed completions",
  "not": "a prediction for your job",
  "drawable": true,
  "points": [[790,4.2],[820,8.3],[860,12.5],[880,16.7],[910,20.8],[940,25],
             [980,29.2],[1010,33.3],[1060,37.5],[1120,41.7],[1150,45.8],
             [1180,50],[1250,54.2],[1290,58.3],[1330,62.5],[1390,66.7],
             [1420,70.8],[1480,75],[1580,79.2],[1610,83.3],[1720,87.5],
             [2300,91.7],[2400,95.8],[2600,100]],
  "p50_s": 1150,
  "p90_s": 2400,
  "p95_s": 2400,
  "robustness": { "method": "per_contributor" },
  "max_s": 2600,
  "confidence": {
    "level": "very_low",
    "score": 0.21,
    "basis": "exact_model",
    "n": 24,
    "contributors": 3,
    "newest_age_s": 85739,
    "why": "Limited by an upper bound we cannot pin down, nothing measured for 24 hours."
  }
}

(The API prints one points pair per line; the array is folded here for readability. Every value is verbatim.)


GET /v1/coverage

What has been measured at all, in the last 30 days. Open, never delayed, no parameters.

Behaviour

crowdsourced is false when every row for that model came from the project's own prober and only one key is behind it. answerable is true as soon as a single measurement exists — there is no hard gate on this route. confidence here is the level string only, computed the same way as elsewhere.

Example

Captured from https://batchwatch.dev, 2026-08-25 14:04 UTC. The first three entries of the models array; the array continued past them.

curl https://batchwatch.dev/v1/coverage
{
  "window": "30d",
  "models": [
    {
      "provider": "anthropic",
      "model": "claude-haiku-4-5",
      "mode": "batch",
      "n": 1,
      "contributors": 1,
      "probes": 1,
      "crowdsourced": false,
      "latest_at": 1787664703,
      "last_measured_age_s": 1970,
      "answerable": true,
      "confidence": "very_low"
    },
    {
      "provider": "google",
      "model": "gemini-3.7-flash",
      "mode": "batch",
      "n": 1,
      "contributors": 1,
      "probes": 1,
      "crowdsourced": false,
      "latest_at": 1787665954,
      "last_measured_age_s": 719,
      "answerable": true,
      "confidence": "very_low"
    },
    {
      "provider": "openai",
      "model": "gpt-5-nano",
      "mode": "batch",
      "n": 1,
      "contributors": 1,
      "probes": 1,
      "crowdsourced": false,
      "latest_at": 1787665183,
      "last_measured_age_s": 1490,
      "answerable": true,
      "confidence": "very_low"
    }
  ]
}

latest_at is a unix timestamp in seconds — the only timestamp in the API that is not also given as ISO-8601.


GET /v1/should-i-batch

Gated. "Batch or sync, given my deadline?" The route does not decide for you: you send your own limit and it answers against it.

Parameters

NameTypeRequiredDefaultNotes
providerstringnoopenai
modelstringyes
riskstringnop90p50, p90 or p95. Not validated here — an unknown value silently falls back to p90 (RISK_Q[risk] ?? 0.9). Contrast /v1/estimate-batchtime, which rejects it.
max_waitdurationno900, 15m, 2h, 1d. A value that does not parse is treated as absent.
input_tokensnumbernoOmit it and no cost figures are produced.
output_tokensnumbernoThe best cost basis: you know it.
max_tokensnumbernoUsed as a ceiling when output_tokens is absent; the saving shown is then the most you could save.
n_shardsnumbernoPresence adds the fanout block. The number of shards in one run.
sync_budgetdurationno0Time to reserve for re-running stragglers synchronously. Only read when n_shards is present.

Duration syntax: an integer or decimal, optionally followed by s, m, h or d. No suffix means seconds.

Fan-out: one run of N shards

A real job is rarely one request. You shard an eval across twenty calls and you need all twenty in before the deadline — and that is not the question "will one make it?".

The arithmetic is unforgiving. At p90 per shard, P(all 20 on time) is not 0.9. It is 0.9^20 = 0.12. Send n_shards and you get it computed against the measured distribution for that model:

curl 'https://batchwatch.dev/v1/should-i-batch?provider=openai&model=gpt-5.6-sol&max_wait=900&n_shards=20&sync_budget=60'   -H "authorization: Bearer $BATCHWATCH_KEY"
{
  "fanout": {
    "verdict": "stage_release_window",
    "n_shards": 20,
    "n": 81,
    "deadline_s": 900,
    "p_ontime": 0.9167,
    "p_correct": 1,
    "p_shard_ok": 0.9167,
    "p_all_ontime": 0.1755,
    "p_all_ok": 0.1755,
    "independence": "assumed_conservative",
    "release_window": {
      "cutoff_s": 840,
      "sync_budget_s": 60,
      "expected_outstanding_frac": 0.0833,
      "expected_sync_fallback_shards": 2
    },
    "note": "Batch the bulk now: about 18 of your 20 shards land by 840s. Re-run the remaining ~2 synchronously at 840s and you hit your 900s deadline with 60s to spare."
  }
}

release_window is the useful part. Rather than answering "batch or don't", it gives you a staged plan: batch everything, cut off at cutoff_s, and re-run whatever is still outstanding synchronously inside the budget you reserved.

Two things to know about the numbers

Independence is assumed, and that is conservative. Shards share the provider's queue, so they are correlated in reality. p^N assumes they are not, which overstates the risk that some shard misses. It is a safe upper bound on risk, never an optimistic one. The response says so in independence_note.

p_correct counts failures, not censoring. Jobs that came back failed or expired count against it. Jobs marked abandoned do not — those are runs where the measuring client stopped waiting, so the true duration is unknown and longer. Counting them as provider failures would blame the provider for our own impatience.

Without n_shards, the response is byte-for-byte what it was before the block existed.

Verdicts

verdictMeaning
run_batchThe planning number fits inside your max_wait.
run_syncIt does not — or the median fits but the upper bound does not.
batch_atToo slow now, but a specific UTC hour is fast enough. Carries batch_at_hour_utc and batch_wait_at_target_s. Never offered when confidence is none or very_low.
no_deadline_givenYou sent no max_wait. Carries required_patience_s.
insufficient_dataOnly reachable when the handler is called without evidence, which the HTTP route never does.

The decision is made on planning_wait_s (the interval's upper end, or the provider's window), never on observed_wait_s. Both are in the response, so the gap is visible.

Cost

Prices live in src/stats.js (PRICING), keyed provider/model. At the time of writing only nine OpenAI models have rates. Batch is exactly half of sync on both input and output.

If output tokens are unknown, no cost is produced: cost_basis.known is false and the raw rates are handed back instead. Absence gives absence — the code deliberately does not assume zero output tokens. If the model is not in PRICING at all, the whole cost_basis block is omitted.

Example — no deadline given, thin data

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC, no key. This call consumed one of the twenty free trial calls.

curl 'https://batchwatch.dev/v1/should-i-batch?provider=openai&model=gpt-5-nano&input_tokens=1000'
{
  "provider": "openai",
  "model": "gpt-5-nano",
  "risk": "p90",
  "batch": { "p50_s": 1144, "p90_s": 1144, "n": 1 },
  "sync": null,
  "cost_basis": {
    "known": false,
    "why": "Output tokens are decided by the model, not by you, and nobody has measured enough real jobs on this model for us to estimate them. We will not guess on your behalf.",
    "rates_usd_per_mtok": { "input": 0.05, "output": 0.4 },
    "note": "Batch is exactly half of sync on both rates, so your saving is half of whatever you would have paid. Send output_tokens if you have a figure, or max_tokens for an upper bound."
  },
  "confidence": {
    "level": "very_low",
    "score": 0.13,
    "basis": "exact_model",
    "n": 1,
    "contributors": 1,
    "newest_age_s": 1502,
    "why": "Limited by only 1 measurements, a single contributor, an upper bound we cannot pin down."
  },
  "planning_basis": "sla_prior",
  "robustness": {
    "method": "pooled",
    "contributors_voting": 0,
    "note": "Pooled across all measurements: fewer than 3 contributors have enough data to vote, so a single contributor could move this number. Confidence is capped accordingly."
  },
  "observed_wait_s": 1144,
  "planning_wait_s": 86400,
  "verdict": "no_deadline_given",
  "required_patience_s": 86400,
  "note": "Batch needs 1144s of patience at p90. Send max_wait and we answer against your limit.",
  "contributors": 1,
  "basis": "exact_model",
  "freshness": {
    "last_measurement_at": "2026-08-25T13:39:43.000Z",
    "last_measurement_age_s": 1502,
    "last_measurement_age": "25 min",
    "stale": false
  },
  "trial": {
    "calls_used": 4,
    "calls_total": 20,
    "calls_left": 16,
    "note": "Free trial - no contribution needed yet."
  }
}

Example — with a deadline

Captured from a local wrangler dev instance with 24 synthetic measurements from 3 keys. Synthetic durations, real response shape. Note the verdict: the median fits inside the 45-minute limit, but the tail cannot be bounded, so the answer is run_sync.

{
  "provider": "openai",
  "model": "gpt-5-mini-docs",
  "risk": "p90",
  "batch": { "p50_s": 1150, "p90_s": 2400, "n": 24 },
  "sync": null,
  "confidence": {
    "level": "very_low",
    "score": 0.21,
    "basis": "exact_model",
    "n": 24,
    "contributors": 3,
    "newest_age_s": 85633,
    "why": "Limited by an upper bound we cannot pin down, nothing measured for 24 hours."
  },
  "planning_basis": "sla_prior",
  "robustness": {
    "method": "per_contributor",
    "contributors_voting": 3,
    "note": "Median of 3 established contributors' own figures. A contributor earns a vote by measuring on at least 3 separate days, so neither more data nor more accounts can move this number."
  },
  "observed_wait_s": 2400,
  "planning_wait_s": 86400,
  "your_limit_s": 2700,
  "batch_wait_s": 2400,
  "verdict": "run_sync",
  "note": "The median says 2400s, inside your 2700s limit - but on 24 measurements we cannot rule out 86400s. Limited by an upper bound we cannot pin down, nothing measured for 24 hours. Run sync until the data is thicker.",
  "contributors": 3,
  "basis": "exact_model",
  "freshness": {
    "last_measurement_at": "2026-08-24T14:27:04.000Z",
    "last_measurement_age_s": 85633,
    "last_measurement_age": "24 hours",
    "stale": false
  },
  "trial": { "calls_used": 1, "calls_total": 20, "calls_left": 19, "note": "Free trial - no contribution needed yet." }
}

There is no cost_basis in that response because gpt-5-mini-docs is not in the price list.


GET /v1/estimate-batchtime

Gated. "How long will my job take?" — answered by finding the measurements that resemble your job in size and reporting what those did.

Parameters

NameTypeRequiredDefaultNotes
providerstringnoopenai
modelstringyes
riskstringnop50p50, p90, p95. Validated: anything else is 422. Note the default differs from /v1/should-i-batch.
input_tokensnumbernoMust be greater than 0 to be used; otherwise treated as absent.

Size matching

The band is multiplicative, not additive: the route tries input_tokens / f to input_tokens * f for f in 2, 4, 8, and takes the narrowest band with at least 10 measurements. basis becomes size_matched when a band was found, all_sizes when it was not, sla_prior when there is nothing at all.

size_effect compares the band with jobs of other sizes. detected: true means the ratio fell below 0.85 or rose above 1.18. detected: null means only jobs of one rough size have ever been measured, so the question cannot be answered at all.

Example

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC, no key. Consumed a trial call.

curl 'https://batchwatch.dev/v1/estimate-batchtime?provider=openai&model=gpt-5-nano&input_tokens=50000&risk=p50'
{
  "provider": "openai",
  "model": "gpt-5-nano",
  "risk": "p50",
  "live": false,
  "delayed_by_s": 900,
  "input_tokens": 50000,
  "estimate_batchtime_s": 1144,
  "confidence": {
    "level": "very_low",
    "score": 0.13,
    "basis": "exact_model",
    "n": 1,
    "contributors": 1,
    "newest_age_s": 1522,
    "why": "Limited by only 1 measurements, a single contributor, an upper bound we cannot pin down."
  },
  "basis": "all_sizes",
  "plan_for_s": 86400,
  "plan_basis": "sla_prior",
  "interval_s": { "lo": 1144, "hi": null, "bounded": false },
  "n": 1,
  "contributors": 1,
  "robustness": {
    "method": "pooled",
    "contributors_voting": 0,
    "note": "Pooled across all measurements: fewer than 3 contributors have enough data to vote, so a single contributor could move this number. Confidence is capped accordingly."
  },
  "freshness": {
    "last_measurement_at": "2026-08-25T13:39:43.000Z",
    "last_measurement_age_s": 1522,
    "last_measurement_age": "25 min",
    "stale": false
  },
  "measurement": "observed completions of comparable jobs",
  "not": "a model of how your job will behave",
  "size_effect": {
    "detected": false,
    "note": "Not enough measurements near 50000 input tokens, so this is the model's overall queue time, not a size-matched one."
  },
  "trial": { "calls_used": 5, "calls_total": 20, "calls_left": 15, "note": "Free trial - no contribution needed yet." }
}

Example — rejected risk value

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC:

curl 'https://batchwatch.dev/v1/estimate-batchtime?provider=openai&model=gpt-5-nano&risk=p99'
{ "error": "risk must be p50, p90 or p95" }

Status 422. Verified in the same run that this did not consume a free trial call: the counter stood at 7 before it, the 422 came back, and the next successful gated call reported calls_used: 8.


GET /v1/conditions

Gated. "Is the queue slow right now?" — the last hour against the 30-day baseline.

Parameters

NameTypeRequiredDefault
providerstringnoopenai
modelstringyes

Behaviour

The hour window is 3600 + delay seconds long and is cut at now - delay, so a delayed caller does not get a live signal for free. verdict is much_slower at ratio 3 or above, slower_than_usual at 1.5 or above, normal below that, and unknown when either side of the ratio is missing.

confidence on this route is the coarse string from confidenceOf()insufficient, low, medium, high — not the object used elsewhere. It is insufficient below MIN_N (20) measurements or MIN_KEYS (3) contributors, and the route still answers.

Example

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC, no key. Consumed a trial call.

curl 'https://batchwatch.dev/v1/conditions?provider=openai&model=gpt-5-nano'
{
  "provider": "openai",
  "model": "gpt-5-nano",
  "window": "1h",
  "live": false,
  "delayed_by_s": 900,
  "completed": { "n": 1, "p50_s": 1144, "p90_s": 1144 },
  "baseline_30d": { "n": 1, "p50_s": 1144, "p90_s": 1144 },
  "ratio_p50": 1,
  "confidence_note": "Thin data - treat this as a hint, not a reading.",
  "verdict": "normal",
  "running": { "oldest_s": null },
  "confidence": "insufficient",
  "contributors": 1,
  "trial": { "calls_used": 6, "calls_total": 20, "calls_left": 14, "note": "Free trial - no contribution needed yet." }
}

GET /v1/distribution

Gated. p50 through p99 plus an hourly profile. The only route with a hard threshold: below 20 measurements from 3 distinct contributors it refuses, and explains why in the response.

Parameters

NameTypeRequiredDefaultNotes
providerstringnoopenai
modelstringyes
modestringnobatch
windowdurationno30dSame duration syntax as max_wait. An unparseable value falls back to 30 days.

Thresholds come from the MIN_N and MIN_KEYS environment variables (currently 20 and 3 in wrangler.toml).

Example — refused

Captured from https://batchwatch.dev, 2026-08-25 14:05 UTC, no key. The refusal is a 200, so it consumed a trial call.

curl 'https://batchwatch.dev/v1/distribution?provider=openai&model=gpt-5-nano'
{
  "provider": "openai",
  "model": "gpt-5-nano",
  "mode": "batch",
  "verdict": "insufficient_data",
  "live": false,
  "delayed_by_s": 900,
  "n": 1,
  "contributors": 1,
  "confidence": "insufficient",
  "required": { "n": 20, "contributors": 3 },
  "why": "This is the one route with a hard threshold. A full distribution with an hourly profile drawn on a handful of jobs would let you read one account's working pattern out of it. The other routes answer at any n, with the thinness stated.",
  "instead": "/v1/wait?provider=openai&model=gpt-5-nano answers now, with graded confidence.",
  "trial": { "calls_used": 7, "calls_total": 20, "calls_left": 13, "note": "Free trial - no contribution needed yet." }
}

Example — answered

Captured from a local wrangler dev instance with 24 synthetic measurements from 3 keys, called with a contributor key. Synthetic durations, real shape.

{
  "provider": "openai",
  "model": "gpt-5-mini-docs",
  "mode": "batch",
  "window_s": 2592000,
  "live": false,
  "delayed_by_s": 300,
  "n": 24,
  "p50_s": 1215,
  "p75_s": 1505,
  "p90_s": 2126,
  "p95_s": 2385,
  "p99_s": 2554,
  "max_s": 2600,
  "dropped_outliers": 0,
  "contributors": 3,
  "confidence": "low",
  "by_hour_utc": [
    { "hour_utc": 12, "n": 9, "p50_s": 1610, "p90_s": 2440, "p95_s": 2520 },
    { "hour_utc": 13, "n": 12, "p50_s": 1090, "p90_s": 1286, "p95_s": 1308 }
  ],
  "quota": {
    "tier": "contributor",
    "calls_used": 4,
    "calls_limit": 10000,
    "calls_left": 9996,
    "window": "7 days"
  }
}

by_hour_utc only lists hours with at least 5 measurements. dropped_outliers counts measurements discarded for exceeding 8 times the median (OUTLIER_FACTOR).

Note that the percentiles on this route come from distribution() directly — they are not the per-contributor robust figures used by /v1/wait and /v1/curve. That is why p50_s here (1215) differs from p50_s on /v1/curve (1150) over the same 24 measurements. Both numbers are correct; they answer slightly different questions. See interpreting.md.