Cookbook: A Batch on a Settled Task
Summarise 50 documents in one batch on a catalogue task that has settled: upload, create, poll, download, and read which model answered each line, on which service tier, and what it cost.
A batch sends many requests in one file and returns the answers in another. This cookbook sends 50 lines to a catalogue task that has settled: we have measured it, and we know which models clear its bar. On a settled task the batch starts every line on the cheapest of them for your input, on the provider’s batch service tier.
It shows each step: upload the file, create the batch, poll it, download the output and error files, and read each line’s model, service tier and cost. At the end, it works out what the per-call fee comes to at higher volumes.
Everything in What the run printed below was printed by this cookbook’s own code, from a
dated run against https://api.fronset.ai. None of it was typed.
The 50 documents are The Federalist Nos. 1–50 (1787–88), which are in the public domain. The script downloads them from Project Gutenberg.
What you need
- An inference key (
llmb_i_…) for the files and the batch, and a management key (llmb_m_…) to read the task, the rate and your usage. See the Quickstart for both. - Prepaid credit. A catalogue task runs on our provider accounts, and its model usage is paid from credit.
- Python with
pip install openai(it bringshttpx), or curl andjq.
export FRONSET_INFERENCE_KEY=llmb_i_...
export FRONSET_MANAGEMENT_KEY=llmb_m_...
python cb3_batch_settled_task.py --out-dir ./cb3_out
1. The task, and why it matters that it has settled
The task is structured-content-summarization, from our catalogue
(benchmark page). Its maturity is in the
task detail:
r = httpx.get("https://api.fronset.ai/api/inference/v1/management/capabilities/"
"structured-content-summarization", headers=MGMT)
print(r.json()["maturity"]["status"], r.json()["batch_eligible"])
cost_optimised means the task has settled. Calls go to the cheapest model that clears its
bar, and we no longer compare models on every call. A batch goes on the provider’s batch
service tier when three things hold for its first line:
| Condition | Why |
|---|---|
| The task has settled, so one model answers | A task still being measured compares several models on a call, and that can’t be sent as one provider batch |
| The task is batch-eligible | Some tasks answer a person or a live pipeline, and are kept off the batch tier |
| The model that answers supports batch | Not every provider has a batch tier |
If any of them fails, the batch still completes. Its lines run one at a time, like synchronous calls, and their service tier says so.
2. Build the file
One line per request. Each line is a chat-completions request in template mode: the task’s
template builds the prompt from the variables in llmbench.prompt_vars, so messages is
empty. GET /v1/models/structured-content-summarization lists the variables.
line = {"custom_id": "federalist-01", "method": "POST", "url": "/v1/chat/completions",
"body": {"messages": [],
"llmbench": {"prompt_vars": {
"content_text": essay,
"content_title": "The Federalist No. 1: General Introduction",
"content_type": "essay",
"content_url": "https://www.gutenberg.org/ebooks/1404",
"summary_structure": "## Summary\nTwo sentences: the paper's claim, "
"and its main argument."}}}}
Two things are left out on purpose:
model. It is sent once, on the create call (step 4). Every line of a batch must name the same task, so sending it once means the lines cannot disagree.response_format. On a catalogue task the output schema comes with the task, and aresponse_formatis refused.
custom_id must be unique within the file. A file holds at most 10,000 lines and 100 MB.
3. Upload it
upload = client.files.create(file=("cb3_input.jsonl", data), purpose="batch")
The upload only checks that every line is JSON. The full check of each line happens after you create the batch.
4. Create the batch
batch = client.batches.create(
input_file_id=upload.id,
endpoint="/v1/chat/completions",
completion_window="24h",
metadata={"cookbook": "3"},
extra_body={"llmbench": {"model": "structured-content-summarization"}},
)
The llmbench block on the create call is ours, not OpenAI’s. It holds the values every
line shares: model, system, selection, forced_model, template and
response_format. A mistyped key in it is refused on this call, before anything runs.
The create returns status: "validating". Two clocks start:
completion_windowis a budget per attempt, not for the whole batch. Each provider’s batch tier also has our own limit of one hour per attempt, and an attempt waits for whichever is shorter. If a line’s model has not answered by then, we cancel that attempt and send the line to the next model, again on a batch tier where it has one, with a fresh budget.expires_atis 24 hours after the create. A line still open then lands in the error file with codebatch_expired.
The client is created with max_retries=0. A create that the SDK retried after a network
error would make a second batch, and every line of it would run and be billed.
5. Poll, and optionally take a webhook
while True:
batch = client.batches.retrieve(batch.id)
if batch.status in {"completed", "failed", "expired", "cancelled"}:
break
time.sleep(30)
Polling is authoritative. A webhook is best-effort: use it to poll sooner, never instead of polling.
To get one, register an endpoint once with the management key:
POST /api/inference/v1/management/webhook-endpoints/ with {"url": "https://…"}. The
response shows the signing secret once; store it. Every batch you create afterwards sends a
batch.completed (or failed, expired, cancelled) event there. The event carries the
batch object, not the results. Verify each delivery over the raw body before parsing it:
def verify_webhook(secret, timestamp, raw_body, signature_header):
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature_header or "")
timestamp is the X-Timestamp header and signature_header is X-Signature.
6. Download the files and read every line
outputs = client.files.content(batch.output_file_id).text.splitlines()
for raw in outputs:
line = json.loads(raw)
mirror = line["response"]["body"]["llmbench"]
print(line["custom_id"], mirror["model"], mirror["service-tier"], mirror["cost-usd"])
Each output line is a normal chat completion, with the llmbench mirror inside its body:
| Field | What it tells you |
|---|---|
model, provider | the model that answered this line |
service-tier | batch when the line ran on the provider’s batch tier |
cost-usd | the answering model’s list cost for this line. It is not the bill: see What this costs |
band | how much evidence we hold for that model on this task |
A line that failed is in the error file, with an OpenAI-shaped error and a code.
Keep your own copy of both files. On the free plan they are kept for 30 days. On
shared_anon and private, an output or error file is deleted shortly after your first
download, or after 72 hours if you never download it.
Why two models answered
The batch chooses its model from its first line, and the choice can depend on that line’s
input: it is the model that clears the task’s bar at the lowest expected cost for an input
like that one. For this
file it was gpt-6-luna, and all 50 lines went to OpenAI’s batch tier together.
OpenAI answered 30 lines within the hour. At our one-hour limit per attempt, we cancelled the
other 20 and sent them to the next model, claude-haiku-5-5, again on a batch tier
(Anthropic’s). No line ran synchronously: the output shows service-tier: batch on all 50.
A cancelled attempt that returned nothing is not billed.
The batch object reports "llmbench": {"executed_lane": "drain"}. drain means the task ran
as a settled task: one model per attempt, no comparison. A task still being measured reports
explore, and its lines run one at a time, where a line can compare several models before
one answers.
A line can also move to another model if its model fails on it, for example if the output
doesn’t match the task’s schema. The retry goes to a batch tier when that model has one;
otherwise it runs synchronously, and its service-tier says so.
What the run printed
Printed by the cookbook's own code, run on 2026-10-14:
run: 2026-10-14 12:45 UTC against https://api.fronset.ai/v1
task: structured-content-summarization
maturity cost_optimised (25 of 27 model groups at MEDIUM or better)
batch_eligible True
inputs: The Federalist Nos. 1-50 (50 lines, 686,320 bytes; source sha256 0b92fadc9d7a)
batch created: status validating, expires 2026-10-15 12:45 UTC
polling
+ 0s validating 0/0 done, 0 failed
+ 30s in_progress 0/50 done, 0 failed
+ 4432s completed 50/50 done, 0 failed
finished in 73 min 52 s; executed lane: drain
lines: 50 in the output file, 0 in the error file
answered by
gpt-6-luna (openai) 30 of 50
claude-haiku-5-5 (anthropic) 20 of 50
service tier
batch 50 of 50
first answer (federalist-01): {"summary": "In *The Federalist No. 1: General Introduction*, Publius argues that the states should adopt the proposed Constitution, which he regards as the safest course for their liberty, dignity, and happiness; the choice also tests whet...
what this run cost us
tokens 189,548 in, 29,328 out
list cost (sum of cost-usd) $0.016996 (the answering model's list cost per line; not the bill)
published batch rate $0.058 in / $0.290 out per 1M tokens (quality 0.90, floor HIGH, since 2026-10-08)
model usage $0.019499 (the tokens at the published batch rate)
per-call fee $0.0000 (50 calls, numbers 211-260 this month; the first 10,000 carry no fee)
credit fee 5.5% of credit bought (minimum $0.80), paid when you buy; none bought for this run| This run | |
|---|---|
| lines completed, failed | 50, 0 |
time from create to completed | 73 min 52 s |
| models that answered | gpt-6-luna 30 of 50, claude-haiku-5-5 20 of 50 |
| lines on the batch tier | 50 of 50 |
| model usage at the published batch rate | $0.019499 (189,548 tokens in, 29,328 out) |
| per-call fee | $0 (inside the first 10,000 calls this month) |
What this costs
- Model usage on a settled catalogue task is its published rate, per 1M input and
output tokens, and the rate for the batch lane is published separately from the
synchronous one.
GET /api/billing/v1/rates/structured-content-summarizationlists both; the script quotes the batch rate and multiplies it by the batch’s tokens. It is drawn from prepaid credit. How a published rate is set is in Pricing Terms §1.3. - The per-call fee counts every line that completes as one call; submitting the batch is not a call, and a line that fails carries no fee (§3.1).
- The credit fee is paid when you buy credit, not per call (§2).
The per-call fee at higher volumes
This is arithmetic from the published card in Pricing Terms §3, not a measured run. The first 10,000 calls a month carry no fee; then $2.00 per 1,000 up to 50,000, $1.50 per 1,000 up to 250,000, and $1.00 per 1,000 above that. Each band is charged only on the calls inside it.
| Calls in a month | Fee | Worked out |
|---|---|---|
| 50 (this run, if nothing else ran) | $0 | inside the first 10,000 |
| 10,000 (one full batch file) | $0 | inside the first 10,000 |
| 50,000 | $80 | 40,000 × $2.00 / 1,000 |
| 300,000 (a 10,000-line batch every night for 30 nights) | $430 | $80 + 200,000 × $1.50 / 1,000 + 50,000 × $1.00 / 1,000 |
These count calls made by any route in the month, synchronous and batch together.
The whole cookbook
Python
#!/usr/bin/env python3
"""Fronset cookbook 3: a batch of 50 lines on a settled catalogue task.
Summarise The Federalist Nos. 1-50 in one batch. The task has settled (we know
which model clears its bar), so every line goes to that one model on the
provider's batch lane. The script uploads the file, creates the batch, polls it,
downloads the output and error files, and reads each line's model, service tier
and cost. The fee arithmetic for larger volumes is on the cookbook page.
pip install openai # httpx comes with it
export FRONSET_INFERENCE_KEY=llmb_i_... # /v1: files and batches
export FRONSET_MANAGEMENT_KEY=llmb_m_... # task read, rate quote, usage
python cb3_batch_settled_task.py --out-dir ./cb3_out
Keys are read from the environment only. Nothing here prints a key.
The inputs are The Federalist Nos. 1-50 (1787-88, public domain), from Project
Gutenberg eBook #1404.
"""
import argparse
import collections
import datetime as dt
import hashlib
import hmac
import io
import json
import os
import re
import sys
import time
import httpx
import openai
from openai import OpenAI
API = os.environ.get("FRONSET_API_BASE", "https://api.fronset.ai")
TASK = "structured-content-summarization"
SOURCE_URL = "https://www.gutenberg.org/cache/epub/1404/pg1404.txt"
SOURCE_PAGE = "https://www.gutenberg.org/ebooks/1404"
# The per-call fee card (Pricing Terms §3): the first 10,000 calls a month carry
# no fee, then $2.00 / $1.50 / $1.00 per 1,000, graduated.
FEE_BANDS = [(10_000, 0.0), (50_000, 2.00), (250_000, 1.50), (None, 1.00)]
# Terminal batch statuses: polling stops at any of these.
TERMINAL = {"completed", "failed", "expired", "cancelled"}
class Transcript:
"""What the run printed: the page shows `lines`; ids go to `record` only."""
def __init__(self):
self.lines, self.record = [], {}
def say(self, line=""):
print(line)
self.lines.append(line)
def mgmt_headers():
return {"Authorization": f"Bearer {os.environ['FRONSET_MANAGEMENT_KEY']}"}
def per_call_fee(calls_before, calls):
"""Fee for `calls` more calls this month, after `calls_before` already made."""
fee, lower = 0.0, 0
for upper, per_1k in FEE_BANDS:
top = upper if upper is not None else calls_before + calls
in_band = max(0, min(calls_before + calls, top) - max(calls_before, lower))
fee += in_band * per_1k / 1000
lower = top
return fee
def refused(what, r):
"""Stop with the API's own reason. Never a key."""
try:
body = r.json()
except ValueError:
body = {}
if not isinstance(body, dict):
body = {}
sys.exit(f"{what}: HTTP {r.status_code}, code {body.get('code')}: {body.get('error')}")
# --- Step 1. The inputs: one line per paper, in template mode ---------------
def federalist_papers(count):
"""[(number, subtitle, text)] for The Federalist Nos. 1..count."""
raw = httpx.get(SOURCE_URL, timeout=60, follow_redirects=True).content
text = raw.decode("utf-8-sig").replace("\r\n", "\n")
start = text.find("*** START OF")
end = text.find("*** END OF")
text = text[text.find("\n", start) + 1:end] if start >= 0 and end > start else text
parts = re.split(r"(?m)^FEDERALIST No\. (\d+)\s*$", text)
papers, seen = [], set()
for number, body in zip(parts[1::2], parts[2::2]):
n = int(number)
if n in seen or n > count:
continue
seen.add(n)
lines = [ln.strip() for ln in body.strip().split("\n")]
subtitle = next((ln for ln in lines if ln), "")
marker = "To the People of the State of New York:"
essay = body.split(marker, 1)[1] if marker in body else body
papers.append((n, subtitle, essay.strip()))
papers.sort()
return papers, hashlib.sha256(raw).hexdigest()
def batch_file(papers):
"""One JSONL line per paper. `model` is not on the lines: it is hoisted on
the create call, so it cannot disagree between lines."""
out = io.BytesIO()
for n, subtitle, essay in papers:
line = {"custom_id": f"federalist-{n:02d}", "method": "POST",
"url": "/v1/chat/completions",
"body": {"messages": [],
"llmbench": {"prompt_vars": {
"content_text": essay,
"content_title": f"The Federalist No. {n}: {subtitle}",
"content_type": "essay",
"content_url": SOURCE_PAGE,
"summary_structure": "## Summary\nTwo sentences: the "
"paper's claim, and its main argument.",
}}}}
out.write((json.dumps(line) + "\n").encode("utf-8"))
return out.getvalue()
# --- Step 5 (optional). Verifying a webhook on your own server -------------
def verify_webhook(secret, timestamp, raw_body, signature_header):
"""True when an X-Signature header is ours. HMAC-SHA256 over the RAW bytes
"<X-Timestamp>." + body, with the endpoint's secret. Verify before parsing.
This script polls instead; this is the check your webhook receiver runs."""
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature_header or "")
def task_state(t):
"""The task's maturity and its batch flag, from the management API."""
r = httpx.get(f"{API}/api/inference/v1/management/capabilities/{TASK}",
headers=mgmt_headers(), timeout=60)
if r.status_code != 200:
refused("task read refused", r)
task = r.json()
m = task.get("maturity") or {}
t.say(f"task: {TASK}")
t.say(f" maturity {m.get('status', '(absent)')} "
f"({m.get('mature_groups')} of {m.get('total_groups')} model groups at "
f"{m.get('threshold_band')} or better)")
t.say(f" batch_eligible {task.get('batch_eligible')}")
return task
def calls_this_month(today):
"""Delivered calls so far this month, for the per-call fee arithmetic."""
r = httpx.get(f"{API}/api/inference/v1/usage", headers=mgmt_headers(), timeout=30,
params={"from": today.replace(day=1).isoformat(),
"to": today.isoformat(), "group_by": "role"})
r.raise_for_status()
return sum(row["calls"] for row in r.json()["rows"] if row["key"] == "delivered")
def batch_rate(task):
"""The published batch-lane rate a line of this task resolves to."""
mg = f"{API}/api/inference/v1/management"
params = {"lane": "batch", "eligibility_floor": "MEDIUM"}
if task.get("selection_policy_slug"):
r = httpx.get(f"{mg}/selection-policies/{task['selection_policy_slug']}",
headers=mgmt_headers(), timeout=30)
if r.status_code != 200:
refused("selection policy read refused", r)
policy = r.json()
params["quality_step"] = f"{policy['quality_threshold']:.2f}"
params["eligibility_floor"] = policy["min_confidence"] or "MEDIUM"
if task.get("model_group"):
params["model_group"] = task["model_group"]
r = httpx.get(f"{API}/api/billing/v1/rates/{TASK}/quote", headers=mgmt_headers(),
timeout=30, params=params)
if r.status_code == 404:
return None
if r.status_code != 200:
refused("rate quote refused", r)
q = r.json()
res = q.get("resolved") or {}
if not (q.get("priced") and res.get("input") and res.get("output")):
return None
return res
def read_lines(client, file_id, keep_as):
"""Download one result file, keep a local copy, and parse it."""
if not file_id:
return []
raw = client.files.content(file_id).text
if keep_as:
with open(keep_as, "w") as f:
f.write(raw)
return [json.loads(ln) for ln in raw.splitlines() if ln.strip()]
def main():
p = argparse.ArgumentParser()
p.add_argument("--count", type=int, default=50, help="papers to summarise (1-85)")
p.add_argument("--poll-seconds", type=int, default=30)
p.add_argument("--out-dir", help="keep the input, output and error files here")
p.add_argument("--page-dir", help="write the transcript here as JSON")
p.add_argument("--record-json", help="write the file and batch ids here")
a = p.parse_args()
today = dt.datetime.now(dt.timezone.utc).date()
t = Transcript()
t.say(f"run: {dt.datetime.now(dt.timezone.utc):%Y-%m-%d %H:%M} UTC against {API}/v1")
# No automatic retries: a retried create would make a second batch, and
# every line of it would run and be billed again.
client = OpenAI(base_url=f"{API}/v1", api_key=os.environ["FRONSET_INFERENCE_KEY"],
timeout=120, max_retries=0)
task = task_state(t)
# Step 1. The input file.
papers, digest = federalist_papers(a.count)
data = batch_file(papers)
t.say()
t.say(f"inputs: The Federalist Nos. {papers[0][0]}-{papers[-1][0]} ({len(papers)} lines, "
f"{len(data):,} bytes; source sha256 {digest[:12]})")
if a.out_dir:
os.makedirs(a.out_dir, exist_ok=True)
with open(os.path.join(a.out_dir, "cb3_input.jsonl"), "wb") as f:
f.write(data)
# Step 2. Upload it.
upload = client.files.create(file=("cb3_input.jsonl", data), purpose="batch")
t.record["input_file_id"] = upload.id
# Step 3. Create the batch. `model` is hoisted into the llmbench block.
before = calls_this_month(today)
batch = client.batches.create(
input_file_id=upload.id,
endpoint="/v1/chat/completions",
completion_window="24h",
metadata={"cookbook": "3"},
extra_body={"llmbench": {"model": TASK}},
)
t.record["batch_id"] = batch.id
created = time.monotonic()
t.say(f"batch created: status {batch.status}, expires "
f"{dt.datetime.fromtimestamp(batch.expires_at, dt.timezone.utc):%Y-%m-%d %H:%M} UTC")
# Step 4. Poll. Polling is authoritative; a webhook is a nudge to poll now.
t.say()
t.say("polling")
last = None
while True:
try:
batch = client.batches.retrieve(batch.id)
except (openai.APIConnectionError, openai.InternalServerError) as e:
# A read is safe to repeat: wait and poll again.
print(f" poll failed ({type(e).__name__}); trying again")
time.sleep(a.poll_seconds)
continue
if batch.status != last:
rc = batch.request_counts
t.say(f" +{int(time.monotonic() - created):>5}s {batch.status:<11} "
f"{rc.completed if rc else 0}/{rc.total if rc else 0} done, "
f"{rc.failed if rc else 0} failed")
last = batch.status
if batch.status in TERMINAL:
break
time.sleep(a.poll_seconds)
elapsed = int(time.monotonic() - created)
extra = (batch.model_extra or {}).get("llmbench") or {}
t.say(f"finished in {elapsed // 60} min {elapsed % 60} s; "
f"executed lane: {extra.get('executed_lane', '(not reported)')}")
# Step 6. Download both files and read every line.
out_dir = a.out_dir
outputs = read_lines(client, batch.output_file_id,
out_dir and os.path.join(out_dir, "cb3_output.jsonl"))
errors = read_lines(client, batch.error_file_id,
out_dir and os.path.join(out_dir, "cb3_error.jsonl"))
t.record.update(output_file_id=batch.output_file_id, error_file_id=batch.error_file_id)
models, tiers = collections.Counter(), collections.Counter()
list_cost, tokens_in, tokens_out, sample = 0.0, 0, 0, None
for line in outputs:
body = (line.get("response") or {}).get("body") or {}
mirror = body.get("llmbench") or {}
models[f"{mirror.get('model')} ({mirror.get('provider')})"] += 1
tiers[mirror.get("service-tier") or "(absent)"] += 1
list_cost += float(mirror.get("cost-usd") or 0)
usage = body.get("usage") or {}
tokens_in += usage.get("prompt_tokens") or 0
tokens_out += usage.get("completion_tokens") or 0
if sample is None and body.get("choices"):
sample = (line["custom_id"], body["choices"][0]["message"]["content"])
error_codes = collections.Counter()
for line in errors:
err = line.get("error") or (((line.get("response") or {}).get("body")) or {}).get("error") or {}
error_codes[err.get("code") or "(no code)"] += 1
t.say()
t.say(f"lines: {len(outputs)} in the output file, {len(errors)} in the error file")
for code, n in error_codes.most_common():
t.say(f" error {code}: {n}")
t.say("answered by")
for name, n in models.most_common():
t.say(f" {name:<44} {n} of {len(outputs)}")
t.say("service tier")
for name, n in tiers.most_common():
t.say(f" {name:<44} {n} of {len(outputs)}")
if sample:
text = sample[1].strip().replace("\n", " ")
t.say(f"first answer ({sample[0]}): {text[:240]}{'...' if len(text) > 240 else ''}")
# Step 7. What it cost.
t.say()
t.say("what this run cost us")
t.say(f" tokens {tokens_in:,} in, {tokens_out:,} out")
t.say(f" list cost (sum of cost-usd) ${list_cost:.6f} (the answering model's "
"list cost per line; not the bill)")
rate = batch_rate(task)
if rate:
r_in, r_out = float(rate["input"]["rate"]), float(rate["output"]["rate"])
t.say(f" published batch rate ${r_in:.3f} in / ${r_out:.3f} out per 1M tokens "
f"(quality {rate['quality_step']}, floor {rate['eligibility_floor']}, since "
f"{rate['output']['effective_from'][:10]})")
t.say(f" model usage ${(tokens_in * r_in + tokens_out * r_out) / 1e6:.6f} "
"(the tokens at the published batch rate)")
else:
t.say(" published batch rate NONE in force for this shape: billed at cost")
done = len(outputs)
t.say(f" per-call fee ${per_call_fee(before, done):.4f} ({done} calls, numbers "
f"{before + 1:,}-{before + done:,} this month; the first 10,000 carry no fee)")
t.say(" credit fee 5.5% of credit bought (minimum $0.80), paid when you buy; "
"none bought for this run")
if a.page_dir:
with open(os.path.join(a.page_dir, "cb3_batch.json"), "w") as f:
json.dump({"run_date": f"{today:%Y-%m-%d}", "transcript": t.lines}, f, indent=2)
if a.record_json:
with open(a.record_json, "w") as f:
json.dump(t.record, f, indent=2)
if __name__ == "__main__":
main()
Download cb3_batch_settled_task.py
curl
#!/usr/bin/env bash
# Fronset cookbook 3 in curl: a batch of lines on a settled catalogue task.
# Needs curl and jq. Keys come from the environment and are never printed.
#
# export FRONSET_INFERENCE_KEY=llmb_i_...
# bash cb3_batch_settled_task.sh cb3_input.jsonl
#
# cb3_input.jsonl: one line per request, in template mode, with no "model"
# (it is hoisted on the create call). The Python version builds it from
# The Federalist Nos. 1-50; run it once with --out-dir to get the file.
set -euo pipefail
API="${FRONSET_API_BASE:-https://api.fronset.ai}"
AUTH="Authorization: Bearer ${FRONSET_INFERENCE_KEY}"
INPUT="${1:?usage: cb3_batch_settled_task.sh <input.jsonl>}"
# 1. Upload the file.
FILE_ID=$(curl -sS "$API/v1/files" -H "$AUTH" \
-F purpose=batch -F "file=@${INPUT}" | jq -r .id)
# 2. Create the batch, with the task hoisted into the llmbench block.
BATCH_ID=$(curl -sS "$API/v1/batches" -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"input_file_id\": \"$FILE_ID\", \"endpoint\": \"/v1/chat/completions\",
\"completion_window\": \"24h\", \"metadata\": {\"cookbook\": \"3\"},
\"llmbench\": {\"model\": \"structured-content-summarization\"}}" | jq -r .id)
# 3. Poll until the batch is terminal. Polling is authoritative.
while :; do
B=$(curl -sS "$API/v1/batches/$BATCH_ID" -H "$AUTH")
STATUS=$(jq -r .status <<<"$B")
echo "$(date -u +%H:%M:%S) $STATUS $(jq -c .request_counts <<<"$B")"
case "$STATUS" in completed|failed|expired|cancelled) break ;; esac
sleep 30
done
jq '.llmbench // {}' <<<"$B"
# 4. Download the output and error files, then read each line's mirror.
OUT=$(jq -r '.output_file_id // empty' <<<"$B")
ERR=$(jq -r '.error_file_id // empty' <<<"$B")
[ -n "$OUT" ] && curl -sS "$API/v1/files/$OUT/content" -H "$AUTH" -o cb3_output.jsonl
[ -n "$ERR" ] && curl -sS "$API/v1/files/$ERR/content" -H "$AUTH" -o cb3_error.jsonl
if [ -f cb3_output.jsonl ]; then
echo "answered by:"; jq -r '.response.body.llmbench.model' cb3_output.jsonl | sort | uniq -c
echo "service tier:"; jq -r '.response.body.llmbench["service-tier"]' cb3_output.jsonl | sort | uniq -c
echo -n "sum of cost-usd: "
jq -s 'map(.response.body.llmbench["cost-usd"] | tonumber) | add' cb3_output.jsonl
echo -n "tokens in, out: "
jq -s '[map(.response.body.usage.prompt_tokens) | add,
map(.response.body.usage.completion_tokens) | add]' -c cb3_output.jsonl
fi
if [ -f cb3_error.jsonl ]; then
echo "errors:"; jq -r '(.error.code // .response.body.error.code)' cb3_error.jsonl | sort | uniq -c
fi
Download cb3_batch_settled_task.sh
Worth knowing
- Every line must name the same task, selection, pin and template. A file whose lines disagree fails the whole batch at validation, with the line numbers.
- A
modelthat names no task fails its line. A batch only calls tasks that exist. - You can have a limited number of batches open at once.
GET /api/inference/v1/batches/limitsshows yours, and one too many is a 429too_many_open_batches. - Cancelling stops new lines from starting. Lines already running finish, and they are in the output.
- The header and field names start
llmbenchand the keys startllmb_. Those are the wire names and they stay as they are.
The Terms of Service, Privacy Policy and Pricing Terms govern use of the API. Where anything on this page differs from those, those govern.