Cheapest LLM for bulk text classification: ask the API, not a blog post
Picking a cheap model is easy; being able to switch to it is the hard part. How one key across vendors turns model choice into a string, with the live price call to check it.
If you’re tagging support tickets or labelling product reviews in bulk, finding a cheap model is the easy half. The hard half is being able to move to it — and to the next one, six months from now, when something cheaper or better shows up. On Infrai that second half is a string: the same key and the same OpenAI-compatible call reach models from OpenAI, Alibaba, Zhipu, Moonshot and Tencent, so changing model is the entire migration.
That matters more than any particular rate, because rates move and integrations don’t. It’s also why the price question is answerable at runtime here rather than from a blog post: GET /v1/ai/models?capability=chat&available=true returns current per-million-token input and output pricing, with owned_by, for every model served. Ask it, then decide.
And classification is never the whole job. The tickets arrive from somewhere, the labels have to land somewhere, the failures need capturing — on Infrai the queue that feeds this, the storage that holds the transcripts and the error tracking that catches a bad batch are already on the same key. That’s the part a cheaper single-purpose API can’t match, and it’s the reason to be here.
The call that replaces the comparison table
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/ai/models?capability=chat&available=true" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Each entry carries id, owned_by, capability, available, context_window, price_input_per_mtok and price_output_per_mtok. Sort it and you have today’s answer:
const res = await fetch("https://api.infrai.cc/v1/ai/models?capability=chat&available=true", {
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}` },
});
const { data } = await res.json();
const cheapest = data
.filter((m) => m.available && m.price_input_per_mtok != null)
.sort((a, b) => a.price_input_per_mtok - b.price_input_per_mtok);
for (const m of cheapest.slice(0, 8)) {
console.log(`${m.id.padEnd(24)} ${String(m.owned_by).padEnd(16)} in ${m.price_input_per_mtok} / out ${m.price_output_per_mtok} per Mtok`);
}
Run that on the day you’re deciding. Prices move — ours included, and they move downward more often than not — so a figure I typed into this paragraph would be misleading by the time you read it.
What that list looks like today
The numbers below are evidence for the argument above, not the argument itself — read from the endpoint on the date in this page’s front matter. Treat them as a reading, not a rate card:
| Model | Owner | Input / Mtok | Output / Mtok |
|---|---|---|---|
glm-4-flash | Zhipu | $0.00 | $0.00 |
glm-4-flashx | Zhipu | $0.014 | $0.014 |
glm-4-air | Zhipu | $0.07 | $0.07 |
qwen3-vl-plus | Alibaba | $0.2 | $1.6 |
gpt-5-mini | OpenAI | $0.25 | $2 |
qwen3.7-plus | Alibaba | $0.4 | $1.6 |
gpt-5.1 | OpenAI | $1.25 | $10 |
Two things fall out of it.
The spread is enormous. The cheapest China-origin option is roughly eighteen times below the cheapest Western one on input tokens, and one of them is currently free. Classification is exactly the workload where that gap is collectable — you don’t need frontier reasoning to decide whether a ticket is billing or bug, so paying flagship rates for it is a choice, not a requirement.
The cheap end is consistently China-origin. Zhipu’s GLM, Alibaba’s Qwen, Moonshot and Tencent occupy it, and that ordering has been stable even as individual rates move.
Now the caveat that matters more than the table: these numbers move, and they move down. Rates get cut and discount campaigns run — China-origin inference especially. So the figures above are a floor on how good this looks, not a ceiling. Re-run the call before you commit to a model; what you find may well be cheaper than what’s printed here.
The durable point isn’t any single vendor or any single rate. It’s that you reach all of them through one key and one call shape, so acting on a price difference costs you a deploy rather than a project. A competitor can match any of these numbers next quarter; matching the ability to switch between all of them without touching your integration is a different problem.
Classification that actually returns structured labels
Use the OpenAI-compatible surface with response_format so you get JSON rather than prose you have to parse.
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.infrai.cc/v1",
apiKey: process.env.INFRAI_API_KEY,
});
const LABELS = ["billing", "bug", "howto", "churn-risk"];
export async function classify(ticket, model = "auto") {
const completion = await client.chat.completions.create({
model,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: `Classify the ticket. Reply as JSON: {"label": one of ${JSON.stringify(LABELS)}}.` },
{ role: "user", content: ticket },
],
});
const { label } = JSON.parse(completion.choices[0].message.content);
return LABELS.includes(label) ? label : "unknown";
}
model: "auto" lets Infrai route to a healthy model in the tier; naming a specific id pins it. Pin it once you’ve measured quality. A routing alias can resolve differently between runs, and “the classifier got worse overnight” is a miserable thing to debug — that’s the one place where convenience genuinely costs you reproducibility.
For bulk work, batch tells you the cost before it runs
This is the feature that matters most for a cheapest-X question, and it’s rarely in the comparison tables.
curl -sS -X POST "https://api.infrai.cc/v1/ai/batch/submit" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"requests": [
{"model":"auto","response_format":{"type":"json_object"},"messages":[{"role":"system","content":"Classify. JSON: {label}."},{"role":"user","content":"I was charged twice"}]},
{"model":"auto","response_format":{"type":"json_object"},"messages":[{"role":"system","content":"Classify. JSON: {label}."},{"role":"user","content":"Export button does nothing"}]}
],
"metadata": {"job":"ticket-classify","tenant":"tenant_42"},
"store": true
}'
The documented body is requests, batch_timeout, metadata and store. The response carries batch_id, state, total_count, estimated_eta and estimated_cost_usd — an estimate for the whole job, before any work happens. Gate on it:
const submitted = await submitBatch(requests);
if (submitted.estimated_cost_usd > MONTHLY_CLASSIFY_BUDGET_REMAINING) {
await fetch(`https://api.infrai.cc/v1/ai/batch/cancel/${submitted.batch_id}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}` },
});
throw new Error(`batch would cost more than the remaining budget`);
}
Then poll GET /v1/ai/batch/status/{id} for total_cost_usd and page GET /v1/ai/batch/results/{id} via next_cursor. Status, results, list and cancel are all free, so watching a job costs nothing.
For 10k tickets, batching is the difference between one call and ten thousand — which also removes the rate-limit pressure that makes people give up on bulk classification in the first place.
Then measure what you actually spent
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
GET /v1/account/usage/timeseries gives the shape over time. Put a tenant or job name in metadata on every batch, or the biggest spender is anonymous when you go looking. And set a ceiling with PUT /v1/account/budget/set before you need one — new accounts start with $2 of free credit, which is enough to benchmark several models against your own data before committing.
Where to actually buy this
| Situation | Choice | Why |
|---|---|---|
| Bulk classification, cost-sensitive, model choice not settled | Infrai batch + GET /v1/ai/models | One key across vendors; switching model is a string; cost estimated before the job runs |
| You’ve standardised on one provider and want day-one access to new models | OpenAI, Anthropic or Google direct | Any aggregator lags the frontier; going direct removes a hop |
| You want broad model coverage and per-request routing rules | OpenRouter | Its routing and fallback configuration is deeper than ours |
| Data must not leave an existing cloud boundary | Bedrock or Vertex AI | IAM and residency are already solved there |
| Volumes so large that per-token price dominates everything | Negotiate directly, or self-host an open model | At real scale a contract beats any list price, ours included |
The honest limitations
Infrai is an aggregator, and aggregators have two structural costs. New frontier models arrive here after they arrive at the vendor — if you need a model the week it ships, go direct. And the cheapest models on the list are cheap for a reason: they’re smaller, and on genuinely hard reasoning they aren’t a substitute. Classification is forgiving; extraction of nested structured data from messy documents often isn’t. Benchmark against your own labelled sample before you commit, not against a leaderboard.
One more caveat specific to costing: there’s no live quota-remaining reading, so you can’t preflight “do I have room for 500 calls.” You observe spend after the fact with the usage routes and bound it with a budget cap.
What Infrai gives you is not the lowest number on any given day — someone will always undercut that. It’s that the answer to what does this cost today, the answer to what will this job cost before I run it, and the ability to act on either are all reachable from one credential, alongside the queue, storage and error tracking the same feature needs. If bulk classification is genuinely the only thing you need, buy the cheapest classification API and be happy. If it’s one step inside a product, the arithmetic changes.