pairwiseLLM provides a unified workflow for generating
and analyzing pairwise comparisons of writing quality
using live LLM APIs (OpenAI, Anthropic, Gemini Developer API, Vertex AI
Gemini API, Together.ai), local models via Ollama, and batch APIs for
OpenAI, Anthropic, and Gemini Developer API.
A typical workflow:
For prompt evaluation and positional-bias diagnostics, see:
For advanced batch processing workflows, see:
pairwiseLLM reads provider keys only from
environment variables, never from R options or global
variables.
| Provider Surface | Environment Variable |
|---|---|
| OpenAI | OPENAI_API_KEY |
| Anthropic | ANTHROPIC_API_KEY |
| Gemini Developer API | GEMINI_API_KEY |
| Vertex AI Gemini API | VERTEX_API_KEY |
| Together | TOGETHER_API_KEY |
backend = "gemini" uses the Gemini Developer API and
backend = "vertex" uses the Vertex AI Gemini API. Those
backends use separate API-key surfaces.
Configure only the key for the cloud backend you plan to use; you do
not need keys for every provider. You can put the relevant key in your
~/.Renviron:
OPENAI_API_KEY="sk-..."
ANTHROPIC_API_KEY="..."
GEMINI_API_KEY="..."
VERTEX_API_KEY="..."
TOGETHER_API_KEY="..."
Check which keys are available:
library(pairwiseLLM)
check_llm_api_keys()
#> Some LLM API keys are not set. This is expected when you use only selected backends.
#> # A tibble: 5 × 4
#> backend service env_var has_key
#> <chr> <chr> <chr> <lgl>
#> 1 openai OpenAI OPENAI_API_KEY TRUE
#> 2 anthropic Anthropic ANTHROPIC_API_KEY TRUE
#> 3 gemini Google Gemini GEMINI_API_KEY TRUE
#> 4 vertex Vertex AI Gemini API VERTEX_API_KEY TRUE
#> 5 together Together.ai TOGETHER_API_KEY TRUE
Ollama runs locally and does not require an API key, just that the Ollama server is running.
The package ships with 20 simulated student writing samples with clear differences in quality:
data("example_writing_samples", package = "pairwiseLLM")
dplyr::slice_head(example_writing_samples, n = 3)
#> # A tibble: 3 × 3
#> ID text quality_score
#> <chr> <chr> <int>
#> 1 S01 "Writing assessment is hard. People write different thing… 1
#> 2 S02 "It is hard to grade writing. Some are long and some are … 2
#> 3 S03 "Assessing writing is difficult because everyone writes d… 3Each sample has:
IDtextCreate all unordered pairs:
pairs <- example_writing_samples |>
make_pairs()
dplyr::slice_head(pairs, n = 5)
#> # A tibble: 5 × 4
#> ID1 text1 ID2 text2
#> <chr> <chr> <chr> <chr>
#> 1 S01 "Writing assessment is hard. People write different things.… S02 "It …
#> 2 S01 "Writing assessment is hard. People write different things.… S03 "Ass…
#> 3 S01 "Writing assessment is hard. People write different things.… S04 "Gra…
#> 4 S01 "Writing assessment is hard. People write different things.… S05 "Wri…
#> 5 S01 "Writing assessment is hard. People write different things.… S06 "It …Sample a subset of pairs:
Randomize SAMPLE_1 / SAMPLE_2 order:
td <- trait_description("overall_quality")
td
#> $name
#> [1] "Overall Quality"
#>
#> $description
#> [1] "Overall quality of the writing, considering how well ideas are expressed,\nhow clearly the writing is organized, and how effective the language and\nconventions are."Or define your own:
Load default prompt:
tmpl <- set_prompt_template()
cat(substr(tmpl, 1, 300))
#> You are a debate adjudicator. Your task is to weigh the comparative strengths of two writing samples regarding a specific trait.
#>
#> TRAIT: {TRAIT_NAME}
#> DEFINITION: {TRAIT_DESCRIPTION}
#>
#> SAMPLES:
#>
#> === SAMPLE_1 ===
#> {SAMPLE_1}
#>
#> === SAMPLE_2 ===
#> {SAMPLE_2}
#>
#> EVALUATION PROCESS (Mental Simulation):
#>
#> 1. **AdPlaceholders required in custom prompt templates:
{TRAIT_NAME}{TRAIT_DESCRIPTION}{SAMPLE_1}{SAMPLE_2}Load a template from file:
For named registration, replacement, removal, and the schemas used in later steps, see Data Schemas and Prompt Management.
The unified wrapper works for OpenAI, Anthropic, Gemini Developer API, Vertex AI Gemini API, Together.ai, and Ollama.
It supports parallel processing and incremental output file saving (resume capability) for all supported backends. The function returns a list containing:
$results: observed outcomes only (canonical
schema)$failed_pairs: scheduled pairs with no observed
outcome$failed_attempts: attempt-level failures (retries,
timeouts, parse errors, invalid winners)# Example using parallel processing and incremental saving
res_list <- submit_llm_pairs(
pairs = pairs_small,
backend = "openai", # also "anthropic", "gemini", "vertex", "together", "ollama"
model = "gpt-4o",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
# New features:
parallel = TRUE,
workers = 2,
save_path = "live_results.csv"
)service_tier is provider-specific rather than portable
across backends. Gemini Developer API and Vertex AI Gemini API currently
accept the public values "standard", "flex",
and "priority", but Vertex is live-only and uses a
different wire format internally.
# Gemini Developer API live request
res_gemini <- submit_llm_pairs(
pairs = pairs_small,
backend = "gemini",
model = "gemini-3.5-flash-lite",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
service_tier = "priority"
)
# Vertex AI Gemini API live request
res_vertex <- submit_llm_pairs(
pairs = pairs_small,
backend = "vertex",
model = "gemini-3.8-flash",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
service_tier = "flex"
)For OpenAI, service_tier = "flex" requests lower-cost,
slower Flex processing when the selected model supports it. Capacity may
be unavailable; Flex is not priority routing. Service-tier meanings are
provider-specific.
Preview results:
# Successes are in the $results tibble
dplyr::slice_head(res_list$results, 5)
# Failures (if any) are in $failed_pairs
if (nrow(res_list$failed_pairs) > 0) {
print(res_list$failed_pairs)
}
# Attempt-level failures (if any) are in $failed_attempts
if (nrow(res_list$failed_attempts) > 0) {
print(res_list$failed_attempts)
}Each row in $results includes: - custom_id
(uses pair_uid if supplied; otherwise defaults to
LIVE_<ID1>_vs_<ID2>) - ID1,
ID2 - parsed <BETTER_SAMPLE> tag →
better_sample and better_id - canonical
aliases/keys: A_id, B_id,
winner_pos, ordered_key,
unordered_key, pair_uid,
received_at, backend, model -
thoughts (reasoning text, if available) and content (final answer)
Convert the LLM output (specifically the $results tibble
for submit_llm_pairs() output) to a 3-column BT
dataset:
# res_list: output list from submit_llm_pairs()
# We extract the $results tibble for modeling
bt_data <- build_bt_data(res_list$results)
dplyr::slice_head(bt_data, 5)and/or a dataset for Elo modeling:
Fit model:
Summarize results:
The output includes:
sirt engine)Outputs:
Batch helpers support "openai",
"anthropic", and "gemini".
backend = "vertex" is rejected explicitly because Vertex
batch mode is not implemented in this series.
In addition to the standard batch helpers, you can split a large job
into multiple segments using llm_submit_pairs_multi_batch()
and then poll all of them with llm_resume_multi_batches().
This is particularly useful when you have many pairs or want to ensure
that you can resume if the session ends.
# Generate a small set of pairs
pairs_small <- example_writing_samples |>
make_pairs() |>
sample_pairs(n_pairs = 10, seed = 4321) |>
randomize_pair_order(seed = 8765)
td <- trait_description("overall_quality")
tmpl <- set_prompt_template()
# Split into two batches and include reasoning/chain-of-thought
multi_job <- llm_submit_pairs_multi_batch(
pairs = pairs_small,
backend = "openai",
model = "gpt-5.1",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
n_segments = 2,
output_dir = "myjob",
write_registry = TRUE,
include_thoughts = TRUE
)
# Poll and merge results. Combined results are written to
# "myjob/combined_results.csv" or the directory you specify.
res <- llm_resume_multi_batches(
jobs = multi_job$jobs,
interval_seconds = 30,
write_combined_csv = TRUE
)
head(res$combined)For large jobs, it is often useful to estimate token usage and cost
before launching a live run or submitting a batch.
pairwiseLLM includes
estimate_llm_pairs_cost(), which runs a small
pilot (paid live calls) and then estimates the rest of
the job by calibrating input tokens from prompt byte length.
The output includes both:
budget_quantile)If you are running a discounted batch workflow, set
mode = "batch" and supply a batch_discount
multiplier.
# Create a moderate set of pairs
pairs_big <- example_writing_samples |>
make_pairs() |>
sample_pairs(n_pairs = 200, seed = 123) |>
randomize_pair_order(seed = 456)
td <- trait_description("overall_quality")
tmpl <- set_prompt_template()
est <- estimate_llm_pairs_cost(
pairs = pairs_big,
backend = "anthropic", # "openai", "anthropic", "gemini", "together"
model = "claude-sonnet-4-5",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
mode = "batch",
batch_discount = 0.5, # set to 1 for no discount
n_test = 10, # paid pilot calls (live)
budget_quantile = 0.9, # p90 output tokens
cost_per_million_input = 3.0, # fill in your provider pricing
cost_per_million_output = 15.0
)
est$summarySuppose a two-pair live pilot records input-token counts of 20 and 40
and completion-token counts of 10 and 30. The prompt-byte calibration
predicts 60 and 80 input tokens for the two remaining pairs. With
budget_quantile = 0.9, R’s type-7 sample quantile of
c(10, 30) is 28.
At input and output prices of 1 currency unit per million tokens and
batch_discount = 0.5, the estimator calculates:
mean(c(10, 30)) * 2 = 40 output;28 * 2 = 56;(60 + 40 + 0.5 * (140 + 40)) / 1e6 = 0.000190; and(60 + 40 + 0.5 * (140 + 56)) / 1e6 = 0.000198.The pilot observations are included once at live prices. They are not averaged into the estimated remaining count and are not discounted.
Avoid paying twice: reuse pilot results
The estimator returns the original pilot output object and the pairs
not included in the pilot (remaining_pairs). Use
remaining_pairs to submit only the remaining work after you
are satisfied with the estimate. Pilot judgments are not automatically
merged into the later submission result:
remaining_pairs <- est$remaining_pairs
# Example: submit only the remaining pairs as a batch
batch <- llm_submit_pairs_batch(
backend = "anthropic",
model = "claude-sonnet-4-5",
pairs = remaining_pairs,
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl)
results <- llm_download_batch_results(batch)Notes:
Most users use the unified interface, but backend helpers are available.
submit_openai_pairs_live()build_openai_batch_requests()run_openai_batch_pipeline()parse_openai_batch_output()submit_anthropic_pairs_live()build_anthropic_batch_requests()run_anthropic_batch_pipeline()parse_anthropic_batch_output()submit_gemini_pairs_live()build_gemini_batch_requests()run_gemini_batch_pipeline()parse_gemini_batch_output()submit_vertex_pairs_live()vertex_compare_pair_live()together_compare_pair_live()submit_together_pairs_live()ollama_compare_pair_live()submit_ollama_pairs_live()ensure_only_ollama_model_loaded()Reasoning/thinking constraints, partial-result handling, retries, and local-resource guidance are covered in Provider Controls and Recovery.
check_llm_api_keys()
#> All known LLM API keys are set: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, VERTEX_API_KEY, TOGETHER_API_KEY.
#> # A tibble: 5 × 4
#> backend service env_var has_key
#> <chr> <chr> <chr> <lgl>
#> 1 openai OpenAI OPENAI_API_KEY TRUE
#> 2 anthropic Anthropic ANTHROPIC_API_KEY TRUE
#> 3 gemini Google Gemini GEMINI_API_KEY TRUE
#> 4 vertex Vertex AI Gemini API VERTEX_API_KEY TRUE
#> 5 together Together.ai TOGETHER_API_KEY TRUEUse batch APIs for >40 pairs. Split a large job into multiple
segments using llm_submit_pairs_multi_batch() and then
poll/download all of them with
llm_resume_multi_batches()
Use compute_reverse_consistency() +
check_positional_bias(); see Prompt
Template Positional Bias Testing for a full example.
Mercer, S. H. (2026). Getting started with pairwiseLLM [R package vignette]. Comprehensive R Archive Network. https://doi.org/10.32614/CRAN.package.pairwiseLLM