Halve Your AI Bill with Batch API
Compare chat and batch API costs across 50 OpenRouter classifications.
Introduction
30 Second Summary
Running the same small text task fifty times can leave you waiting at your screen while the bill grows with every request.
In this project, you will classify fifty short text rows twice using the OpenRouter Batch API. You’ll finish with aligned results plus a side-by-side comparison of elapsed time and account cost.
What You'll Build
Picture opening one table that proves whether batching preserved every classification while showing its effect on wait time plus account cost.
By the end of this project, you'll have:
- A repeatable 50-row classification that runs through normal chat calls with visible token counts for each result.
- A single batch job that you can track from its queued status to completion.
- A joined results file plus a side-by-side comparison showing elapsed time and account cost for both approaches.
- Secret Mission: Complete an optional challenge that extends your finished batch-processing workflow.
Are there any prerequisites?
You'll need an OpenRouter account with credits.
Bring a dataset of fifty short text rows plus any programming language that can send a file in a POST request.
Before We Start
Your cost comparison needs both runs to start from the same dependable environment. A missing package or hidden credential could stop the experiment before you collect any measurements.
You'll prepare Python 3.11 or later with the Requests package. You'll also prepare authenticated access to OpenRouter.
In this step, get ready to:
- Prepare the local Python workspace.
- Secure the OpenRouter API key.
- Confirm that account usage is visible.
Verify Python and prepare the workspace
Python runs the scripts that send each workload. Version 3.11 or later gives the project a consistent starting point.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Terminal on macOS or PowerShell on Windows.
- Press Enter to open the terminal.
Before you check, do you expect the installed Python version to meet the project minimum?
macOS
- Check the active Python version by running this command:
python3 --version
What does this command show?
The output identifies the Python interpreter available through python3. Use the version number to choose the matching outcome below.
Windows
- Check the active Python version by running this command:
python --version
What does this command show?
The output identifies the Python interpreter available through python. Use the version number to choose the matching outcome below.
✔️ I see the required version
Your output shows Python 3.11 or later. Your interpreter is ready for the project.
ⓧ I see an older version
The active interpreter is below Python 3.11. Install a current supported release before continuing.
On macOS, the Python.org installation can coexist with Apple's system Python. Keep the installer defaults so its python3 command becomes available.
- Open the official Python download page.
- Select the installer for your operating system.
- Complete the installer with its default options.
- Close the terminal after installation.
- Reopen the terminal through system search.
- Return to the matching platform tab above to repeat the version check.
ⓧ Command not found
Your terminal cannot find a Python interpreter. The official installer adds the runtime required by this project.
- Open the official Python download page.
- Select the installer for your operating system.
- Complete the installer with its default options.
- Close the terminal after installation.
- Reopen the terminal through system search.
- Return to the matching platform tab above to repeat the version check.
Your project files need one predictable home. The openrouter-batch-costs folder keeps the dataset and results together.
macOS
- Create the project folder on your Desktop by running these commands:
cd ~/Desktop
mkdir openrouter-batch-costs
cd openrouter-batch-costs
What do these commands do?
- The first command moves the terminal to your Desktop.
- The second command creates the openrouter-batch-costs folder.
- The final command makes that folder the terminal's current location.
Windows
- Create the project folder on your Desktop by running these commands:
Set-Location "$HOME\Desktop"
New-Item -Name "openrouter-batch-costs" -ItemType Directory
Set-Location "openrouter-batch-costs"
What do these commands do?
- The first command moves PowerShell to your Desktop.
- The second command creates the openrouter-batch-costs folder.
- The final command makes that folder the terminal's current location.
- Press Cmd+Space on macOS or the Windows key on Windows to return to system search.
- Type the name of your installed code editor.
- Press Enter to start the editor.
- Choose the folder-opening command from the editor's File menu.
- Select openrouter-batch-costs from your Desktop.
You'll see openrouter-batch-costs in the editor's title area or file sidebar. This confirms that the terminal and editor point to the same workspace.
Folder already exists?
A folder creation error can mean that openrouter-batch-costs already exists on your Desktop. Enter that existing folder from the terminal before continuing.
Use Help me enter my existing project folder from Terminal or PowerShell.
Requests gives your Python scripts a direct way to send HTTP calls. Installing it now prepares the transport layer for both comparison runs.
macOS
- Install Requests into the verified Python interpreter by running:
python3 -m pip install requests
What does this command install?
The command asks the active Python interpreter to run pip. Pip installs Requests with the packages it depends on.
Windows
- Install Requests into the verified Python interpreter by running:
python -m pip install requests
What does this command install?
The command asks the active Python interpreter to run pip. Pip installs Requests with the packages it depends on.
Before you test the package, do you expect Python to import Requests successfully?
macOS
- Verify the Requests installation by running:
python3 -c "import requests; print(requests.__version__)"
What should you see?
You'll see the installed Requests version number. That output proves the same Python interpreter can import the package.
Windows
- Verify the Requests installation by running:
python -c "import requests; print(requests.__version__)"
What should you see?
You'll see the installed Requests version number. That output proves the same Python interpreter can import the package.
Requests did not install?
Confirm that the install command uses the same Python command as your successful version check. A network restriction can also prevent pip from downloading the package.
Use Help me diagnose my Requests installation error.
Secure your OpenRouter access
An API key authorizes requests that can spend account credits. An environment variable keeps that credential outside your Python files.
This credential is shown only once after creation. Keep it out of screenshots and project files while you move it into OPENROUTER_API_KEY.
- Visit OpenRouter in your browser.
- Sign in to your account or complete the account creation flow.
- Open the OpenRouter API Keys page.
- Start the API key creation flow.
- Enter batch-cost-comparison as the key name.
- Create the API key.
- Copy the key from its one-time reveal.
macOS
- Store the key in the current terminal session by replacing the placeholder in this command:
export OPENROUTER_API_KEY="your-api-key-here"
What does this command protect?
The command stores the credential in OPENROUTER_API_KEY for the current terminal session. Your later scripts can read it without placing the key in source code.
Keep this terminal open for the remaining steps. A new terminal session requires you to export the variable again.
Windows
- Store the key for future terminal sessions by replacing the placeholder in this command:
setx OPENROUTER_API_KEY "your-api-key-here"
What does this command protect?
The command stores OPENROUTER_API_KEY for your Windows user account. New PowerShell sessions can read the credential without placing it in source code.
- Close the current PowerShell window.
- Press the Windows key to open system search.
- Type PowerShell.
- Press Enter to open a new session.
- Return to openrouter-batch-costs on your Desktop.
Before you verify the variable, what should a safe check reveal without printing the credential itself?
macOS
- Confirm that the environment variable exists by running this safe check:
python3 -c "import os; print(bool(os.getenv('OPENROUTER_API_KEY')))"
What should you see?
You'll see True without seeing the key itself. This proves Python can access the credential safely.
Windows
- Confirm that the environment variable exists by running this safe check:
python -c "import os; print(bool(os.getenv('OPENROUTER_API_KEY')))"
What should you see?
You'll see True without seeing the key itself. This proves Python can access the credential safely.
Seeing False?
On macOS, confirm that you replaced the placeholder before running the export command. Keep using the same terminal session.
On Windows, confirm that you opened a new PowerShell session after running setx.
Use Help me verify OPENROUTER_API_KEY without exposing its value.
Confirm the Usage page is ready
The OpenRouter Activity page is your account-side receipt for this experiment. You return here after each run to collect the cost shown by your own usage.
Before you open the usage view, which measurements do you expect the account dashboard to expose?
- Open the OpenRouter Activity page.
- Sign in if OpenRouter prompts you.
- Confirm that the page displays the Spend metric.
- Confirm that the page displays the Tokens metric.
- Confirm that the page displays the Requests metric.
- Leave the Activity page open for the later cost checks.
That's the starting line secured. Your local tools can now support authenticated requests while the Activity page records their account usage.
✔️ Awesome, I've got everything!
Your Python workspace is ready. Your OpenRouter credential and usage dashboard are also in place.
ⓧ I'd like to double check the final setup
- Confirm Python 3.11 or later is installed.
- Confirm the terminal is open inside openrouter-batch-costs.
- Confirm the code editor has openrouter-batch-costs open.
- Confirm Requests imports through the verified Python interpreter.
- Confirm Python reads OPENROUTER_API_KEY without displaying its value.
- Confirm you are signed in to OpenRouter with access to the Activity usage page.
Your environment is ready. Next, you'll put fifty short rows into a file and send the first row through the normal chat endpoint.
Test a Normal Chat Request
The setup work is complete. Your Python environment now has the pieces needed to reach OpenRouter with an authenticated request.
Batch savings need a normal-request baseline. You will classify one identified row first so the response shape and token counts are clear before the workload grows.
In this step, get ready to:
- Create fifty short rows with stable IDs.
- Send the first row through the normal chat completions endpoint.
- Print the classification plus both token counts.
Create the fifty-row dataset
A CSV file gives every text row a stable identity. Those IDs keep the normal results and later batch results attached to the correct inputs.
- Select the new-file control in your code editor's file sidebar.
- Name the file rows.csv.
You should see rows.csv in the file sidebar.
- Add the header plus the first twenty-five rows by pasting this block into rows.csv:
id,text
row-001,The support team solved my issue quickly
row-002,The app crashed before I could save
row-003,The package arrived on Tuesday
row-004,I loved the clear layout
row-005,My refund has been delayed
row-006,The report contains twelve pages
row-007,The delivery arrived earlier than expected
row-008,The login link expired immediately
row-009,The office closes at six
row-010,The update made the app faster
row-011,The instructions were confusing
row-012,The subscription renews monthly
row-013,The agent was friendly
row-014,The battery died after one hour
row-015,The invoice was sent by email
row-016,The search results were accurate
row-017,My order was missing a cable
row-018,The webinar was recorded
row-019,Checkout was effortless
row-020,The export failed again
row-021,The train departs from platform four
row-022,The dashboard is useful
row-023,The response was unhelpful
row-024,The account was created in April
row-025,The issue was resolved on the first try
How is the dataset structured?
- The id column holds the permanent identifier for each row.
- The text column holds the sentence that the model classifies.
- The first half includes positive, negative, and neutral examples.
- Save rows.csv.
- Confirm that the editor shows row-025 on line 26.
Does the first half look uneven?
Check that the header occupies the first line. Remove any blank lines above id,text.
Confirm that each row contains one ID followed by one text value. Help me check the first half of my CSV dataset for missing rows or malformed columns.
- Place your cursor on the line below row-025.
- Complete the dataset by pasting these remaining rows:
row-026,The video kept buffering
row-027,The file size is three megabytes
row-028,The new feature saves me time
row-029,The notification arrived too late
row-030,The survey closes on Friday
row-031,The setup was simple
row-032,The replacement screen was scratched
row-033,The session lasted forty-five minutes
row-034,The recommendation was perfect
row-035,My payment was rejected twice
row-036,The documentation was updated yesterday
row-037,The team answered every question
row-038,The download speed was too slow
row-039,The package contains five items
row-040,The search found exactly what I needed
row-041,The reminder email was blank
row-042,The office moved to the second floor
row-043,The tutorial was easy to follow
row-044,The report omitted the key totals
row-045,The password changed successfully
row-046,The response took three days
row-047,The appointment is confirmed for noon
row-048,The quality exceeded my expectations
row-049,The menu failed to load
row-050,The receipt lists two products
Why do the IDs keep counting?
The second half continues the same ID sequence. A unique ID gives each future classification a dependable join key.
- Save rows.csv.
- Confirm that the final row uses the ID row-050.
- Confirm that the editor shows the final row on line 51.
You now have fifty identified rows beneath one header. This dataset stays fixed across both request paths.
Do the row numbers stop early?
Check the transition from row-025 to row-026. Remove any duplicate header between the two pasted sections.
Scan the IDs for skipped or repeated numbers. Help me verify that rows.csv contains exactly fifty unique sequential IDs.
✔️ Awesome, I've got everything!
Your dataset contains fifty short text rows with stable IDs from row-001 through row-050.
ⓧ I'd like to double check the full code
id,text
row-001,The support team solved my issue quickly
row-002,The app crashed before I could save
row-003,The package arrived on Tuesday
row-004,I loved the clear layout
row-005,My refund has been delayed
row-006,The report contains twelve pages
row-007,The delivery arrived earlier than expected
row-008,The login link expired immediately
row-009,The office closes at six
row-010,The update made the app faster
row-011,The instructions were confusing
row-012,The subscription renews monthly
row-013,The agent was friendly
row-014,The battery died after one hour
row-015,The invoice was sent by email
row-016,The search results were accurate
row-017,My order was missing a cable
row-018,The webinar was recorded
row-019,Checkout was effortless
row-020,The export failed again
row-021,The train departs from platform four
row-022,The dashboard is useful
row-023,The response was unhelpful
row-024,The account was created in April
row-025,The issue was resolved on the first try
row-026,The video kept buffering
row-027,The file size is three megabytes
row-028,The new feature saves me time
row-029,The notification arrived too late
row-030,The survey closes on Friday
row-031,The setup was simple
row-032,The replacement screen was scratched
row-033,The session lasted forty-five minutes
row-034,The recommendation was perfect
row-035,My payment was rejected twice
row-036,The documentation was updated yesterday
row-037,The team answered every question
row-038,The download speed was too slow
row-039,The package contains five items
row-040,The search found exactly what I needed
row-041,The reminder email was blank
row-042,The office moved to the second floor
row-043,The tutorial was easy to follow
row-044,The report omitted the key totals
row-045,The password changed successfully
row-046,The response took three days
row-047,The appointment is confirmed for noon
row-048,The quality exceeded my expectations
row-049,The menu failed to load
row-050,The receipt lists two products
This reference contains one header plus exactly fifty identified text rows.
Read the first identified row
The first test needs one real input from the shared dataset. Reading it through the script proves that the file structure works before any credits are used.
- Select the new-file control in your code editor's file sidebar.
- Name the file classify_rows.py.
You should see classify_rows.py beside rows.csv in the file sidebar.
- Read the first identified row by pasting this code into classify_rows.py:
import csv
import os
import requests
# Read one identified row so later outputs can be joined safely.
with open("rows.csv", newline="", encoding="utf-8") as file:
reader = csv.DictReader(file)
first_row = next(reader)
print(f"First row: {first_row['id']} | {first_row['text']}")
What does this code do?
- The csv module reads the header names from rows.csv.
- The requests library is ready for the chat request added next.
- The first_row dictionary holds the ID plus the text from the first data row.
- The final line prints both values so you can verify their alignment.
- Save classify_rows.py.
- Run the first dataset check in the terminal with this command:
python classify_rows.py
What should you see?
The terminal prints First row: row-001 | The support team solved my issue quickly. This proves that the script can read the first identified input.
That first check is complete. Your script has preserved the connection between the row ID and its text.
Can the script read the first row?
Confirm that rows.csv sits beside classify_rows.py. Check that the CSV header is exactly id,text.
Check the indentation beneath the with line. Help me diagnose why classify_rows.py cannot read the first row from rows.csv.
Send and inspect the chat request
A normal API call waits for the model response before the script continues. This synchronous result becomes the baseline for the later batch comparison.
This request touches your OpenRouter credit balance. The script sends only one short row.
- Place your cursor below the current print() line in classify_rows.py.
- Add the normal chat request by pasting this code:
MODEL = "openai/gpt-4.1-mini"
CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
# Ask for one constrained label so every row has the same output shape.
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": "Classify the sentiment as positive, negative, or neutral. Reply with exactly one lowercase label."},
{"role": "user", "content": first_row["text"]},
],
"temperature": 0,
"max_completion_tokens": 10,
}
headers = {
"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
"Content-Type": "application/json",
}
# Send one synchronous request and stop if the service rejects it.
response = requests.post(CHAT_URL, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
print(f"Classification: {data['choices'][0]['message']['content'].strip()}")
How does the request work?
- The MODEL constant keeps the selected model identical across later comparisons.
- The system message limits each response to one sentiment label.
- The authorization header reads the existing OPENROUTER_API_KEY environment variable without printing it.
- The response path selects the assistant's classification text from the returned chat completion.
- Save classify_rows.py.
- Run the chat request in the terminal with this command:
python classify_rows.py
What should the response show?
The terminal prints the first row followed by Classification: positive. The model has now classified one real input through the normal endpoint.
Did the chat request stop?
Confirm that the terminal session still has access to OPENROUTER_API_KEY. Confirm that your OpenRouter account has available credits.
Check the copied endpoint plus model name against the code block. Help me diagnose why my first OpenRouter chat request failed without asking me to reveal my API key.
The classification proves that the response body is usable. The remaining baseline detail is the number of input tokens plus the number of generated tokens.
- Place your cursor below the classification output line.
- Add the token accounting output by pasting this code:
# Read the token counts returned with this completion.
usage = data["usage"]
print(f"Prompt tokens: {usage['prompt_tokens']}")
print(f"Completion tokens: {usage['completion_tokens']}")
Why print both token counts?
- The prompt_tokens value measures the model input.
- The completion_tokens value measures the generated label.
- Together they provide the token evidence behind this normal request.
- Save classify_rows.py.
Before you run the final check, which four output lines do you expect the script to print?
- Run the completed first-row test with this command:
python classify_rows.py
What does success look like?
You will see the first row plus its classification. Two more lines show numeric prompt and completion token counts.
Your exact token numbers can differ with model processing. The presence of both numbers proves that usage accounting came back with the response.
Are the token counts missing?
Confirm that the two token print lines appear after data = response.json(). Check that both dictionary keys match the reference code.
Run the saved file again after correcting any indentation or spelling differences. Help me inspect the OpenRouter response usage fields in classify_rows.py.
✔️ Awesome, I've got everything!
Your script reads row-001. It prints the normal classification plus both token counts.
ⓧ I'd like to double check the full code
import csv
import os
import requests
# Read one identified row so later outputs can be joined safely.
with open("rows.csv", newline="", encoding="utf-8") as file:
reader = csv.DictReader(file)
first_row = next(reader)
print(f"First row: {first_row['id']} | {first_row['text']}")
MODEL = "openai/gpt-4.1-mini"
CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
# Ask for one constrained label so every row has the same output shape.
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": "Classify the sentiment as positive, negative, or neutral. Reply with exactly one lowercase label."},
{"role": "user", "content": first_row["text"]},
],
"temperature": 0,
"max_completion_tokens": 10,
}
headers = {
"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
"Content-Type": "application/json",
}
# Send one synchronous request and stop if the service rejects it.
response = requests.post(CHAT_URL, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
print(f"Classification: {data['choices'][0]['message']['content'].strip()}")
# Read the token counts returned with this completion.
usage = data["usage"]
print(f"Prompt tokens: {usage['prompt_tokens']}")
print(f"Completion tokens: {usage['completion_tokens']}")
Your normal request baseline is working. Next, you will run all fifty rows synchronously and measure the total wall-clock time.
Run the Normal Requests
Your first-row test proved that OpenRouter can classify your data. It also exposed the token counts for one normal request.
A useful cost comparison needs a measured baseline across all fifty rows. This step captures the elapsed time plus the cost shown in your account.
In this step, get ready to:
- Run one normal chat completion for each of the 50 rows.
- Create a timed results file containing all fifty classifications.
- Record the normal-run cost from the OpenRouter Usage page.
Run all fifty normal requests
A normal run handles one row at a time. Each request must finish before the next request begins.
The wall-clock timer captures the accumulated wait. Stable row IDs preserve the connection between each source row and its classification.
How should the normal-run flow work?
- The selected model stays unchanged.
- The normal chat request settings stay unchanged.
- A loop sends one normal chat completion for each of the 50 rows.
- Each result pairs the source row ID with its classification.
- The timer surrounds the complete request loop.
- The variable normal_wall_clock_seconds holds the elapsed time.
- CSV writing saves all records in normal_results.csv.
- Switch back to classify_rows.py in the editor from earlier.
- Refactor the existing first-row execution into the normal-run flow described above.
- Save classify_rows.py.
This run spends OpenRouter credits. Its scope is fixed at the 50 short rows in rows.csv.
Before you run this, consider whether the terminal returns immediately or stays busy until all fifty requests finish.
- Run the timed fifty-request script with this command:
python classify_rows.py
What should you see?
- Python executes the saved version of classify_rows.py.
- The terminal stays occupied until all fifty normal requests have returned.
- The completed run prints the normal-run timing.
- The completed run creates normal_results.csv.
- Record the printed timing here: your normal-run wall-clock time.
Did the normal run stop early?
Confirm that the API key remains configured in the terminal from earlier. Check that rows.csv still contains all fifty source rows.
Use the final traceback line to locate the failing section in classify_rows.py.
Help me diagnose why my fifty-request normal run stopped before creating the complete results file.
That is your timing baseline captured. You now have the real wait produced by fifty separate requests.
Check the saved classifications
The results file is evidence that every source row received a classification. Its stable IDs make each saved answer traceable to rows.csv.
- Return to the editor from earlier.
- Select normal_results.csv in the file sidebar.
- Confirm that the file contains exactly 50 classification records.
- Compare the saved row IDs with the IDs in rows.csv.
You should see one stable row ID beside each normal chat classification.
Record the normal-run cost
The local timer measures how long your script waited. The OpenRouter Usage page provides the account-side cost for the same run.
Before you check the Usage page, consider how the fifty-request cost compares with the single-row test.
- Return to the Usage page from earlier.
- Locate the usage generated during the fifty-request normal run.
- Record the displayed normal-run cost here: your normal-run cost.
Your normal-request baseline is complete. You now have an elapsed time from your machine plus a cost from your account.
✔️ Awesome, I've got everything!
Your fifty normal requests produced a complete results file. Their elapsed time plus account cost are ready for the later comparison.
ⓧ I'd like to double check the full code
- Confirm that classify_rows.py still reads all 50 rows from rows.csv.
- Confirm that the selected OpenRouter model remains unchanged.
- Confirm that the script sends one normal chat completion for every row.
- Confirm that the timer stores its result in normal_wall_clock_seconds.
- Confirm that normal_results.csv contains 50 row IDs paired with classifications.
- Confirm that the script prints the normal-run timing.
- Confirm that your recorded wall-clock time is your normal-run wall-clock time.
- Confirm that your recorded account cost is your normal-run cost.
Your normal-request baseline is recorded. Next, you will package the same fifty rows into one batch request.
Submit a Batch Job
Your normal run now gives you a baseline across all 50 rows. This step packages the same classification workload for asynchronous processing.
The OpenRouter Batch API uses custom_id to link each request with its source row. That identifier lets every returned classification find the correct row later.
In this step, get ready to:
- Map all 50 source rows into batch request records.
- Save the complete workload in batch_requests.jsonl.
- Submit the workload to capture its batch ID plus initial status.
Map each source row
Batch results return separately from rows.csv. Stable row IDs protect the order of your classifications while the job runs asynchronously.
- Switch back to classify_rows.py in the code editor from earlier.
- Locate the loop that reads each record from rows.csv.
- Identify the row ID value already saved in normal_results.csv.
- Find the selected model value used by the normal chat requests.
- Find the classification prompt used by the normal chat requests.
You now have the three ingredients required for every batch item. These are the row ID, selected model, and classification prompt.
Build and submit the batch payload
A local JSON Lines file gives you an auditable copy of the submitted workload. Each line in batch_requests.jsonl represents one source row.
OpenRouter accepts the workload as an inline JSON requests array. Your script loads the 50 saved lines into that array before sending the request.
How the batch payload is shaped
- The top-level endpoint value is /v1/chat/completions.
- The top-level model value reuses the model from the normal run.
- The requests array contains exactly 50 items.
- Each item stores its source row ID in custom_id.
- Each item stores the existing classification messages inside body.
- The payload places endpoint before model. The requests array comes after both values.
- The submission uses the existing OPENROUTER_API_KEY in the Authorization header.
- The script sends the payload to https://openrouter.ai/api/v1/batches with an HTTP POST request.
- Implement the batch section described above immediately after the code that prints the normal-run timing.
- Save classify_rows.py.
Why keep a local request file?
The API receives an inline array during submission. The local batch_requests.jsonl file preserves the exact 50 request items used to build that array.
This file gives you a direct way to audit row IDs before joining the results later.
Record the queued job
The create response is your receipt for accepted work. Completed classifications arrive after the asynchronous batch finishes.
Before you run the updated script, do you expect the API to return 50 classifications immediately or a receipt for later work?
- Run the updated classify_rows.py using the same terminal command you used for the normal run.
You will see an id value for the accepted batch. You will also see the initial status value validating.
Strong progress. Your 50-row workload now has an OpenRouter receipt.
Batch submission rejected?
- Check that the selected model has a batch-capable endpoint.
- Confirm every custom_id value is unique within the batch.
- Confirm the payload places endpoint before model.
- Confirm the requests array appears after both top-level values.
Help me diagnose my rejected OpenRouter Batch API submission.
- Record the printed id value here: your OpenRouter batch ID.
- Record the printed status value here: your initial batch status.
- Switch to batch_requests.jsonl in your code editor.
- Confirm the file contains exactly 50 non-empty lines.
- Compare the first custom_id value with the first row ID in rows.csv.
- Compare the final custom_id value with the final row ID in rows.csv.
You should see one batch request for every source row. The initial validating status confirms that OpenRouter persisted the workload and queued it for validation.
Request count or IDs look wrong?
- Check that the batch loop uses the same 50 records loaded from rows.csv.
- Confirm each serialized request ends with one newline in batch_requests.jsonl.
- Confirm custom_id receives the stable row ID instead of the row text.
Help me find why my batch file does not contain 50 correctly mapped requests.
✔️ I have the batch receipt
Your batch request file contains all 50 rows. OpenRouter accepted the workload for asynchronous processing.
ⓧ I'd like to double check the final state
- Confirm classify_rows.py still contains the normal-request workflow.
- Confirm batch_requests.jsonl contains exactly 50 newline-delimited request objects.
- Confirm every request has a unique custom_id matching its source row ID.
- Confirm every request uses the selected model through the top-level model value.
- Confirm every request uses the same classification prompt inside messages.
- Confirm the script submits the workload to https://openrouter.ai/api/v1/batches.
- Confirm you recorded the returned batch ID.
- Confirm the initial response status is validating.
Your asynchronous job is now queued for validation. Next, you will poll its status until the 50 classifications are ready.
Wait for Batch Completion
Your batch request is queued in the OpenRouter Batch API. Now your script needs to follow that job through to completion.
A batch job runs asynchronously. Polling turns the waiting period into a measured result.
In this step, get ready to:
- Poll the batch job by batch_id until its status is completed.
- Record the total wait as batch_wait_seconds.
- Confirm the completed job has output available to download.
Add status polling to the script
The initial queued status only confirms that OpenRouter accepted the workload. Repeated status requests reveal when processing finishes.
- Switch back to classify_rows.py in the code editor from earlier.
- Find the submission block that records batch_id plus the initial batch status.
- Extend that block with one polling loop. Use batch_id for every status request. Pause between requests. Print each returned status. Exit the loop when the status is completed. Store the elapsed time from submission to completion in batch_wait_seconds.
- Save classify_rows.py.
Why poll by batch ID?
Every status request uses batch_id to check the same submitted workload. The identifier keeps every update attached to one batch job.
The batch_wait_seconds measurement starts at submission. It ends when the API reports completed.
Wait for the batch to finish
The completion window can extend to 24 hours. A long pause can still be normal.
Before you start the poller, do you expect the first status check to show completion or more waiting?
- Return to the terminal from earlier.
- Start the updated classify_rows.py workflow with the same run method you used earlier.
The script prints status updates for the submitted batch. The first update may still be queued.
- Leave the terminal process running until the polling loop exits.
The final update reports completed. The script records the elapsed wait in batch_wait_seconds.
Are status updates missing?
Confirm that each status request uses the recorded batch_id. Confirm that the loop pauses after each response.
If authentication fails, confirm that OPENROUTER_API_KEY remains configured in the terminal from earlier.
Help me diagnose a batch polling loop that does not reach completion.
Confirm the completed job
Before the final check, what evidence should prove that the batch output is ready for the next step?
- Review the final status output in the terminal.
- Confirm that the final status is completed.
- Confirm that the batch output is available to download.
- Confirm that batch_wait_seconds contains the elapsed time from submission to completion.
That is the waiting done. Your completed batch now has downloadable output plus a measured wall clock time.
✔️ Awesome, I've got everything!
Your batch has completed. Its output is ready for the row-by-row alignment check.
ⓧ I'd like to double check the full code
- Confirm that your existing environment still uses Python 3.11 or later. Confirm that requests remains installed.
- Confirm that OPENROUTER_API_KEY remains configured. Confirm that the OpenRouter dashboard Usage page remains accessible.
- Confirm that rows.csv still contains exactly 50 short text rows with stable unique row IDs.
- Confirm that normal_results.csv still joins all 50 row IDs to their normal chat classifications.
- Confirm that normal_wall_clock_seconds remains recorded. Confirm that normal_cost remains recorded from the Usage page.
- Confirm that batch_requests.jsonl contains exactly 50 newline-delimited requests with the corresponding row IDs as custom identifiers.
- Confirm that classify_rows.py retains the normal workflow. Confirm that it creates the batch request file. Confirm that it submits the batch. Confirm that it polls by batch_id until the status is completed.
- Confirm that the initial status was queued. Confirm that the final status is completed. Confirm that output is available to download. Confirm that batch_wait_seconds is recorded.
Your batch output is ready. Next, you will download the results and match every classification back to its original row ID.
Download and Match Batch Results
Your completed batch contains fifty answers. The returned order can differ from the order in rows.csv.
Stable row IDs protect each original text from receiving another row's classification. This step retrieves the inline results from the completed OpenRouter Batch API response.
You will save the matched records in batch_results.csv. You will also record the batch charge from your own usage data.
In this step, get ready to:
- Retrieve the completed results for your recorded batch.
- Create a 50-row results file matched by custom row ID.
- Verify the first text-classification pairs.
Retrieve the completed results
A completed batch returns its output inside the batch object's results array. Each item carries the custom_id that came from its original row.
- In the code editor from earlier, return to classify_rows.py.
- Find the end of the polling section that records batch_wait_seconds after the status becomes completed.
- Paste this retrieval block immediately below that section:
# Retrieve the completed batch object because its results are returned inline
batch_output_response = requests.get(
f"https://openrouter.ai/api/v1/batches/{batch_id}",
headers=headers,
)
batch_output_response.raise_for_status()
batch_data = batch_output_response.json()
batch_results = batch_data["results"]
print(f"Downloaded {len(batch_results)} batch results.")
What does this code retrieve?
- The request uses the recorded batch_id to retrieve the completed batch object.
- The results array contains the output for every batch request.
- The count confirms how many result items are available for matching.
- Save classify_rows.py.
Before you run the retrieval, how many completed result items do you expect the recorded batch to return?
- Run the updated script from the project folder by using this command:
python classify_rows.py
What should you see?
You should see Downloaded 50 batch results. This proves that the completed response contains one result for every source row.
Result count is not 50?
Confirm that batch_id refers to the completed job created from the fifty requests in batch_requests.jsonl.
Check that the retrieval request uses the same authenticated headers as the existing submission code.
Help me diagnose my completed OpenRouter batch result count.
Match results by row ID
A CSV file preserves a visible record of the original text beside its classification. A lookup keyed by custom_id keeps that relationship intact even when the batch results arrive in another order.
- In classify_rows.py, place the following function below the retrieval block:
def save_batch_results(rows, batch_results):
# Index classifications by the stable ID sent with each request
classifications_by_id = {}
for result in batch_results:
if result["error"] is not None:
raise RuntimeError(f"Batch item {result['custom_id']} failed.")
classification = result["response"]["body"]["choices"][0]["message"]["content"].strip()
classifications_by_id[result["custom_id"]] = classification
# Write records in the original source order to preserve alignment
with open("batch_results.csv", "w", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(
file,
fieldnames=["row_id", "text", "batch_classification"],
)
writer.writeheader()
for row in rows:
writer.writerow(
{
"row_id": row["row_id"],
"text": row["text"],
"batch_classification": classifications_by_id[row["row_id"]],
}
)
# Print a small sample so the matched pairs are easy to inspect
for row in rows[:5]:
print(f"{row['text']} | {classifications_by_id[row['row_id']]}")
save_batch_results(rows, batch_results)
How does the matching work?
- The classifications_by_id dictionary stores each classification under its returned custom_id.
- The error check stops the export when a batch item has no successful response.
- The loop over rows controls the order written to batch_results.csv.
- The final loop prints five pairs for a quick alignment check.
- Save classify_rows.py.
Before you run the final check, do you expect the first printed texts to follow the source-file order or the returned batch order?
- Create the matched results file by running this command:
python classify_rows.py
What proves the join worked?
You should see five original-text and batch-classification pairs in the terminal. Their texts should follow the first five records in rows.csv.
You should also see batch_results.csv in the project folder. It should contain 50 data rows beneath the header.
- Select batch_results.csv in the code editor's file sidebar.
- Compare its first five text values with the first five text values in rows.csv.
- Confirm that every result row contains a batch classification.
That is the alignment risk handled. Your batch answers now stay attached to the rows that produced them.
Seeing missing or mismatched rows?
Confirm that the row_id values in rows.csv match the custom_id values returned by the batch.
Check that the export loops over the original rows collection instead of the returned result array.
Help me debug the row ID join without exposing my API key.
Record the batch cost
The local results prove that the classifications stayed aligned. Your account usage supplies the measured cost for the completed batch.
- Switch back to the signed-in OpenRouter dashboard from earlier.
- Return to the Usage page.
- Locate the completed batch entry associated with your recorded batch_id.
- Read the cost charged for that batch job.
- Record the value here: your batch cost.
You now have the measured batch cost beside a verified 50-row output. The final comparison can use evidence from your own run.
✔️ Awesome, I've got everything!
Your completed output is matched to all fifty source rows. Your batch cost is also recorded for the final comparison.
ⓧ I'd like to double check the final state
- Confirm classify_rows.py retrieves the completed batch object using batch_id.
- Confirm the script reads the completed results array.
- Confirm each classification is indexed by its returned custom_id.
- Confirm batch_results.csv contains 50 records with row IDs and original text.
- Confirm every record in batch_results.csv contains its completed batch classification.
- Confirm the terminal prints the first five original-text and classification pairs.
- Confirm batch_cost is recorded from the OpenRouter Usage page.
Your batch output is now aligned and priced. Next, you'll place both costs and times side by side.
Compare Time and Cost
Fifty normal classifications now sit beside fifty batch classifications. Your OpenRouter Usage records provide the cost evidence for both approaches.
The final comparison turns four recorded measurements into one result. It places the saving beside the wait required to achieve it.
In this step, get ready to:
- Create comparison.csv from the four recorded measurements.
- Print the two approaches as a side-by-side comparison.
- Explain the cost-versus-latency tradeoff shown by your workload.
Create the comparison table
A fair comparison needs one row per approach. Matching columns place recorded cost beside elapsed time.
Map each measurement to its row
- The Normal row represents the sequential workflow.
- Its cost cell uses normal_cost.
- Its elapsed-time cell uses normal_wall_clock_seconds.
- The Batch row represents the completed batch job.
- Its cost cell uses batch_cost.
- Its elapsed-time cell uses batch_wait_seconds.
- Both cost cells use the same unit recorded from the Usage page.
- Both elapsed-time cells use seconds.
- Switch back to classify_rows.py in the code editor from earlier.
- Find the existing section that prints the first few original-text plus batch-classification pairs.
- Add a self-contained comparison section below that output using the mapping above.
- Make the section write exactly two data rows to comparison.csv.
- Make the section print the completed comparison after writing the file.
- Save classify_rows.py.
Before you run the comparison section, which row do you expect to show the lower cost?
- Select the complete self-contained comparison section in your code editor.
- Execute the selected section with your editor's Python selected-code action.
You should see two comparison rows printed in the output. You should also see comparison.csv appear in the project folder.
You have turned four scattered measurements into one result you can share.
Comparison file missing or incomplete?
Confirm that your selected section includes the file-writing logic. Check that both approach rows are present before the file is closed.
If a value appears under the wrong approach, compare each cell with the mapping above. Keep the Normal measurements separate from the Batch measurements.
Help me debug my comparison section.
Confirm the tradeoff
Cost shows what each approach consumed. Elapsed time shows how long each approach took to deliver all fifty classifications.
How to read the two times
The Normal wall-clock value measures the sequential run from start to finish. Your script waits for each normal response before sending the next request.
The Batch wait value measures asynchronous processing latency until the job reaches its completed status. This delay is the time tradeoff attached to the lower batch cost.
Before you compare the rows, which approach do you expect to favor cost? Which approach do you expect to favor completion speed?
- Select comparison.csv from your code editor's file sidebar.
- Confirm that the file contains one Normal row.
- Confirm that the file contains one Batch row.
- Compare the two recorded cost values.
- Compare the two elapsed-time values.
- Return to the printed comparison in your code editor's output panel.
The Batch row should show a lower recorded cost for this workload. Its elapsed-time value should reflect the longer asynchronous wait.
What does the comparison prove?
Your cost values come from the two OpenRouter usage records. They measure the actual charges created by your fifty-row workload.
Your time values capture two delivery patterns. Normal requests favor immediate sequential progress. Batch processing favors lower cost within an asynchronous completion window.
✔️ Awesome, I've got everything!
Your two-row comparison now connects the final results to their measured cost plus elapsed time. The experiment is complete.
ⓧ I'd like to double check the full code
Your final classify_rows.py retains the normal workflow. It also retains the complete batch workflow plus the alignment checks.
- Confirm that rows.csv contains exactly 50 short text rows with stable unique row IDs.
- Confirm that normal_results.csv contains 50 normal classifications matched to row IDs.
- Confirm that batch_requests.jsonl contains exactly 50 newline-delimited batch requests using those row IDs as custom identifiers.
- Confirm that the recorded batch_id belongs to a completed batch job.
- Confirm that batch_results.csv contains 50 classifications joined to the original rows by custom row ID.
- Confirm that comparison.csv contains exactly one Normal row plus one Batch row.
- Confirm that the Normal row uses normal_cost plus normal_wall_clock_seconds.
- Confirm that the Batch row uses batch_cost plus batch_wait_seconds.
The script should write all three CSV result files. Its final output should include the alignment check plus the cost and time comparison.
That closes the experiment with evidence from your own workload. You can now explain the saving beside the wait it required.
Secret mission
Create a Batch Readiness Gate
Turn your measured costs and timings into a reusable routing gate. Test the gate against urgent work and delay-tolerant work before choosing the best request path.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your local artifacts, pause your workspace, or delete the files entirely. Your completed OpenRouter batch is finished, so it creates no ongoing project usage.
Resources you used:
- The source dataset in rows.csv.
- The workflow script in classify_rows.py.
- The batch request payloads in batch_requests.jsonl.
- The normal classifications in normal_results.csv.
- The joined batch classifications in batch_results.csv.
- The cost and timing comparison in comparison.csv.
- The recorded batch_id reference.
Keep everything running
No action is needed. Choose this if you want to rerun the comparison or inspect the results later.
- Retain rows.csv as the stable input dataset.
- Retain classify_rows.py so you can classify another workload.
- Retain batch_requests.jsonl as a reference for the submitted request format.
- Retain normal_results.csv as the synchronous result record.
- Retain batch_results.csv as the aligned batch result record.
- Retain comparison.csv as your cost and elapsed-time evidence.
- Retain the recorded batch_id for future reference.
Pause - I'll come back to this later
Close your local tools to free memory while preserving the project. The completed batch needs no further polling.
- Close the terminal from earlier.
- Close the code editor from earlier.
- Keep every file in the current project folder unchanged.
- Retain the recorded batch_id for your next session.
Delete - I don't want to use this again
This option removes the local project artifacts. Your completed batch stays finished, so deleting local copies does not restart or resubmit it.
- Remove classify_rows.py through the code editor's file tree from earlier.
- Remove rows.csv through the code editor's file tree from earlier.
- Remove batch_requests.jsonl through the code editor's file tree from earlier.
- Remove normal_results.csv through the code editor's file tree from earlier.
- Remove batch_results.csv through the code editor's file tree from earlier.
- Remove comparison.csv through the code editor's file tree from earlier.
- Remove the recorded batch_id reference from the place where you saved it.
- Confirm the code editor's file tree no longer lists any of the six project files.
Nice Work!
Nice Work!
You did it! You compared normal chat calls with the OpenRouter Batch API across the same 50-row workload.
What you learned:
- Built a normal workflow that classifies all 50 rows. Captured token usage for each response. Measured the wall clock time. Recorded the cost from your Usage page.
- Created a batch workload with 50 newline-delimited requests. Submitted one batch job. Polled it until completion. Recorded its batch ID. Measured its asynchronous wait time.
- Protected result alignment with stable row IDs. Wrote all 50 joined records to batch_results.csv. Placed both measured costs in comparison.csv. Added both measured times to the same table.
- Secret Mission: Extended the workflow through an optional challenge that pushed your batching skills further.
Ready to quiz yourself?