llm-jury
When your classifier is uncertain, let a configurable jury of LLM personas debate and return an auditable verdict.
Overview
llm-jury is an SDK, not a hosted API. Your app imports it directly:
from llm_jury import Jury, PersonaRegistry
The PyPI package is llm-jury-classifier. Do not pip install llm-jury: that name belongs to an unrelated project that also installs an llm_jury module.
It wraps a classifier returning (label, confidence) and adds confidence-based escalation:
- Run primary classifier (fast path)
- Return directly when confidence is high
- Escalate low-confidence cases to persona debate
- Consolidate with a judge strategy
- Return verdict + audit trail
Research Inspiration
llm-jury is inspired by the CEJ (Collaborative Expert Judgment) module described in arXiv:2512.23732. This package generalizes that pattern into a domain-agnostic SDK with pluggable classifiers, multiple debate modes, multiple judge strategies, threshold calibration, and Python + TypeScript distributions.
Install
pip install llm-jury-classifier
Optional extras:
pip install "llm-jury-classifier[sklearn]"
pip install "llm-jury-classifier[huggingface]"
pip install "llm-jury-classifier[all]"
Prerequisites
- Python
>=3.10 - For real LLM calls:
OPENAI_API_KEY(or provider key through your LiteLLM/OpenAI setup)
Quick Start
import asyncio
from llm_jury import Jury, PersonaRegistry
from llm_jury.classifiers import FunctionClassifier
from llm_jury.judges import MajorityVoteJudge
classifier = FunctionClassifier(
fn=lambda text: ("safe", 0.62),
labels=["safe", "unsafe"],
)
jury = Jury(
classifier=classifier,
personas=PersonaRegistry.content_moderation(),
confidence_threshold=0.7,
judge=MajorityVoteJudge(),
)
async def main():
verdict = await jury.classify("borderline message")
print(verdict.label, verdict.confidence, verdict.was_escalated)
asyncio.run(main())
With LLM Classifier
import asyncio
from llm_jury import Jury, PersonaRegistry
from llm_jury.classifiers import LLMClassifier
from llm_jury.judges import MajorityVoteJudge
classifier = LLMClassifier(labels=["safe", "unsafe"])
jury = Jury(
classifier=classifier,
personas=PersonaRegistry.content_moderation(),
confidence_threshold=0.85,
judge=MajorityVoteJudge(),
)
async def main():
verdict = await jury.classify("That group always causes problems")
print(f"Label: {verdict.label}")
print(f"Confidence: {verdict.confidence}")
print(f"Escalated: {verdict.was_escalated}")
if verdict.debate_transcript:
for resp in verdict.debate_transcript.rounds[-1]:
print(f" {resp.persona_name}: {resp.label} ({resp.confidence})")
asyncio.run(main())
SDK Response
jury.classify(text) returns a Verdict. There are two shapes depending on whether the input was escalated. The JSON below is what verdict.to_dict() returns. The samples are illustrative and shortened (persona raw_response is left out). The durations and costs are made up; real escalations take far longer and cost more (see Important Notes for measured numbers).
Fast path (confidence above threshold)
When the primary classifier is confident enough, the verdict is returned directly with no debate.
{
"label": "safe",
"confidence": 0.95,
"reasoning": "Classified by primary classifier with sufficient confidence.",
"was_escalated": false,
"primary_result": {
"label": "safe",
"confidence": 0.95,
"raw_output": { "label": "safe", "confidence": 0.95 },
"cost_usd": 0.0001
},
"debate_transcript": null,
"judge_strategy": "primary_classifier",
"total_duration_ms": 312,
"total_cost_usd": 0.0001,
"persona_failures": 0,
"library_version": "0.2.0",
"created_at": "2026-09-24T10:15:00.123456+00:00",
"judge_details": null,
"debate_degraded": false
}
Escalated (confidence below threshold)
When confidence is too low, the input goes through persona debate and a judge produces the final verdict. In this sample the three personas agree in the opening round, so the debate stops there with no second round and no summary.
{
"label": "unsafe",
"confidence": 1.0,
"reasoning": "The statement is a sweeping negative generalization about an entire group of people, attributing harmful behavior broadly.",
"was_escalated": true,
"primary_result": {
"label": "unsafe",
"confidence": 0.62,
"raw_output": { "label": "unsafe", "confidence": 0.62 },
"cost_usd": 0.0001
},
"debate_transcript": {
"input_text": "Those people always cause problems wherever they go",
"primary_result": { "label": "unsafe", "confidence": 0.62 },
"rounds": [
[
{
"persona_name": "Policy Analyst",
"label": "unsafe",
"confidence": 0.90,
"reasoning": "The statement is a blanket negative generalization targeting a group.",
"key_factors": ["group-targeting language", "sweeping generalization"],
"dissent_notes": null,
"tokens_used": 185,
"cost_usd": 0.0003,
"failed": false
},
{
"persona_name": "Cultural Context Expert",
"label": "unsafe",
"confidence": 0.85,
"reasoning": "While context could soften interpretation, the phrasing is unambiguously negative.",
"key_factors": ["no mitigating context", "derogatory framing"],
"dissent_notes": null,
"tokens_used": 192,
"cost_usd": 0.0003,
"failed": false
},
{
"persona_name": "Harm Assessment Specialist",
"label": "unsafe",
"confidence": 0.92,
"reasoning": "Broad negative generalization risks normalizing prejudice against the targeted group.",
"key_factors": ["potential for real-world harm", "targets unspecified group"],
"dissent_notes": null,
"tokens_used": 178,
"cost_usd": 0.0003,
"failed": false
}
]
],
"duration_ms": 2450,
"total_tokens": 555,
"total_cost_usd": 0.0009,
"summary": null,
"unpriced_calls": 0,
"persona_biases": {
"Policy Analyst": "policy-strict",
"Cultural Context Expert": "tends permissive on context",
"Harm Assessment Specialist": "harm-focused"
}
},
"judge_strategy": "majority_vote",
"total_duration_ms": 2780,
"total_cost_usd": 0.001,
"persona_failures": 0,
"library_version": "0.2.0",
"created_at": "2026-09-24T10:15:03.456789+00:00",
"judge_details": null,
"debate_degraded": false
}
Verdict field reference
| Field | Type | Description |
|---|---|---|
label |
str |
Final classification |
confidence |
float |
Final confidence (0.0 to 1.0) |
reasoning |
str |
Human-readable explanation |
was_escalated |
bool |
Whether debate was triggered |
primary_result |
ClassificationResult |
Fast-path classifier output |
debate_transcript |
DebateTranscript | None |
Full debate audit trail (see below); None if no debate ran |
judge_strategy |
str |
Strategy that produced the verdict, including the fallback markers listed under Troubleshooting |
total_duration_ms |
int |
Wall-clock time (ms) |
total_cost_usd |
float | None |
Primary classifier plus debate and judge cost in USD. None when any part is unknown; a lower bound when debate_transcript.unpriced_calls > 0 |
persona_failures |
int |
Persona calls across the debate that failed (LLM error, unparseable output, or a label outside the configured set) |
debate_degraded |
bool |
Property (also in to_dict()): True when persona_failures > 0, so the verdict was decided by fewer jurors than configured. Useful for routing to human review |
judge_details |
dict | None |
LLMJudge only: key_agreements, key_disagreements, decisive_factor. None for other judges and fallbacks |
library_version |
str |
llm-jury-classifier version that produced the verdict |
created_at |
str |
ISO 8601 UTC timestamp |
Debate transcript fields
| Field | Type | Description |
|---|---|---|
input_text |
str |
The text that was classified |
primary_result |
ClassificationResult |
Primary classifier output |
rounds |
list[list[PersonaResponse]] |
One list per round, in order |
summary |
str | None |
Summariser output in deliberation mode. None when the debate stopped early, in other modes, or when the summariser call failed |
duration_ms |
int |
Debate wall-clock time (ms) |
total_tokens |
int |
Tokens used by persona and summariser calls |
total_cost_usd |
float | None |
Sum of the persona and summariser calls that reported a cost; None when none did |
unpriced_calls |
int |
Debate calls that reported no cost. Non-zero means total_cost_usd is a lower bound |
persona_biases |
dict[str, str] |
Persona name to known_bias, for personas that declare one. The LLM judge sees these |
persona_failures |
int |
Property: failed persona responses across all rounds |
Persona response fields
| Field | Type | Description |
|---|---|---|
persona_name |
str |
Which persona |
label |
str |
This persona's classification |
confidence |
float |
This persona's confidence |
reasoning |
str |
Full reasoning chain |
key_factors |
list[str] |
Key decision factors |
dissent_notes |
str | None |
Rebuttal in deliberation/adversarial modes |
raw_response |
str | None |
The model's raw reply |
tokens_used |
int |
Tokens consumed |
cost_usd |
float | None |
API cost for this call; None when the client reported none |
failed |
bool |
True when this response is a placeholder for a failed persona call. Failed responses stay in the transcript for audit but carry no vote |
Choosing What To Use
Classifiers
| Classifier | When to use | Example |
|---|---|---|
FunctionClassifier |
Wrap an existing model or function | FunctionClassifier(fn=my_model, labels=["a","b"]) |
LLMClassifier |
Primary classifier is an LLM | LLMClassifier(labels=["safe","unsafe"]) |
HuggingFaceClassifier |
Local HuggingFace model | HuggingFaceClassifier("unitary/toxic-bert") |
SklearnClassifier |
Wrap a scikit-learn model | SklearnClassifier(model, labels, vectorizer) |
Built-in Persona Sets
| Method | Domain | Personas |
|---|---|---|
PersonaRegistry.content_moderation() |
Trust & Safety | Policy Analyst, Cultural Context Expert, Harm Assessment Specialist |
PersonaRegistry.legal_compliance() |
Legal/Regulatory | Regulatory Attorney, Business Risk Analyst, Industry Standards Expert |
PersonaRegistry.medical_triage() |
Healthcare | Clinical Safety Reviewer, Contextual Historian, Resource Allocation Analyst |
PersonaRegistry.financial_compliance() |
AML/KYC | AML Investigator, Risk Quant, Business Controls Reviewer |
PersonaRegistry.custom([...]) |
Any domain | Provide your own persona dicts |
Judge Strategies
| Strategy | How it decides | Best for |
|---|---|---|
MajorityVoteJudge() |
Counts persona votes. Confidence = fraction agreeing. | Fast, no extra LLM call |
WeightedVoteJudge() |
Weights votes by persona confidence. | When confidence scores vary significantly |
LLMJudge() |
LLM reads full transcript and synthesises verdict. | Maximum quality, auditable reasoning |
BayesianJudge() |
Bayesian aggregation with optional persona priors. | When you have reliability data on personas |
Debate Modes
| Mode | Behaviour | Best for |
|---|---|---|
independent |
All personas assess in parallel | Fast, low cost |
sequential |
Each persona sees previous responses | Building on earlier assessments |
deliberation (default) |
Full 4-stage CEJ pipeline: Initial Opinions, Structured Debate, Summarisation, Final Judgment | Maximum value; complex edge cases |
adversarial |
Assigns prosecution/defense stances | Stress-testing a classification |
Important Notes
- Temperature is handled automatically. The SDK omits the temperature parameter for reasoning models (
gpt-5*,o1*,o3*, also behind a provider prefix such asopenai/gpt-5-mini). No configuration needed. - Escalation is strictly
< threshold: confidence exactly equal to the threshold does NOT escalate. A missing or non-finite primary confidence (None, NaN) always escalates. - Automatic retry: each LLM call gets 3 attempts in total (the first try plus 2 retries, via tenacity) on connection errors, timeouts, 429 and 5xx responses; other errors fail at once. The client waits for the provider's
Retry-Afterheader when it sends one (capped at 60 s) and backs off exponentially otherwise. Change the count withLiteLLMClient(max_attempts=...). - Default debate mode is deliberation, the full 4-stage CEJ pipeline. For cheaper and faster runs use
DebateConfig(mode=DebateMode.INDEPENDENT). - Deliberation stops early after any round, the opening one included, when the personas' labels are unanimous or
early_stop_min_confidenceis met. A debate that stops early has no summary. - Latency and cost of an escalation: a debate makes several rounds of LLM calls. Live runs in February 2026 with the default
gpt-5-mini, three personas and a mix of debate modes and judges took 27 to 57 s and cost $0.007 to $0.015 per escalated item. Use that as a rough guide only; your models, personas and inputs will change it. The fast path costs one primary classifier call. - Cost tracking: each call's cost is estimated from litellm's model pricing table (not from provider billing), so a model litellm does not price reports
None. Unknown cost isNone, never 0. An escalatedtotal_cost_usdincludes the primary classifier, isNonewhen the primary cost or the whole debate cost is unknown, and is a lower bound whendebate_transcript.unpriced_calls > 0. - The cost cap is checked twice. Before a debate,
estimated_max_debate_cost_usdis compared withmax_debate_cost_usd; the estimate counts every persona call in every round, the summariser in deliberation mode and the judge when it is anLLMJudge, each atestimated_cost_per_persona_usd(default $0.01 per call). During the debate, reported spend is compared with the cap, and calls that reported no cost are charged at that same per-call estimate. - Empty personas disables escalation: If you pass
personas=[], the jury always returns the primary classifier result. - Untrusted input: every prompt fences the input in
<input>tags marked as untrusted data, and labels returned by models are checked against your labels. See Prompt injection and untrusted input for what callers should still do.
API Reference
Public Exports
from llm_jury import (
Jury,
JuryStats,
Persona,
PersonaResponse,
PersonaRegistry,
DebateConfig,
DebateMode,
DebateTranscript,
Verdict,
)
Jury Options
| Option | Default | Description |
|---|---|---|
classifier |
(required) | Primary classifier |
personas |
(required) | List of personas |
confidence_threshold |
0.7 |
Escalation threshold |
judge |
None (defaults to LLMJudge) |
Judge strategy |
debate_config |
None |
Debate configuration |
escalation_override |
None |
Force escalation |
max_debate_cost_usd |
None |
Cost cap for debate |
estimated_cost_per_persona_usd |
0.01 |
Estimated cost of one LLM call (persona, summariser or judge). Used for the pre-flight estimate and charged against the cap for calls that report no cost |
debate_concurrency |
5 |
Max concurrent persona calls |
on_escalation |
None |
Fires when input is escalated to debate. (text, primary_result) -> None |
on_cost_estimate |
None |
Fires with (estimated_max_debate_cost_usd, text) immediately before a debate would run. Return False to skip the debate (verdict marked cost_guard_user_override); return True / None to proceed. |
on_verdict |
None |
Fires once with every verdict classify returns, including fast-path and cost-guard verdicts. (verdict) -> None |
llm_client |
None |
LLM transport override |
logger |
None (uses logging.getLogger(__name__)) |
Logger override |
Methods:
await classify(text): classify a single inputawait classify_batch(texts, concurrency=10, return_exceptions=False): classify multiple inputs. Withreturn_exceptions=True, a failing text yields its exception in-slot instead of rejecting the whole batch. Without it, the first failure raises and no further text starts a debate.
Behavior notes:
- Escalation condition is strictly
< threshold(exactly equal does not escalate). - If
personasis empty, jury escalation is effectively disabled. - Failed persona calls (LLM error, unparseable output, or a label outside the configured set) are kept in the transcript as placeholders with
failed=Truebut carry no vote. If the whole final round failed, judges return the primary classifier result.Verdict.persona_failurescounts them andVerdict.debate_degradedis True when any persona failed; use it to route degraded verdicts to human review. Jury.estimated_max_debate_cost_usd(property) is the pre-flight estimate: (persona calls + 1 summariser call in deliberation mode + 1 judge call for anLLMJudge) xestimated_cost_per_persona_usd. Persona calls arelen(personas) x max_roundsin deliberation mode andlen(personas)in the other modes. If it exceedsmax_debate_cost_usd, no debate runs andjudge_strategyiscost_guard_pre_flight.- If spend during the debate exceeds
max_debate_cost_usd, the result falls back to the primary classifier withjudge_strategyset tocost_guard_primary_fallback. Calls that reported no cost are charged atestimated_cost_per_persona_usd. on_cost_estimateruns after the escalation decision but before any LLM call for the debate, and before themax_debate_cost_usdguard. Lets you layer per-tenant budgets, time-of-day gates, etc. on top of the hard cap.
Stats: jury.stats.total, fast_path, escalated, escalation_rate, cost_savings_vs_always_escalate.
DebateConfig Options
| Option | Default | Meaning |
|---|---|---|
mode |
deliberation |
Debate mode |
max_rounds |
2 |
Max deliberation rounds |
include_primary_result |
true |
Include primary result in prompts |
include_confidence |
true |
Include confidence in prompt context |
early_stop_min_confidence |
None |
Opt-in early stop for deliberation mode. When set, the debate ends after any round, the opening one included, whose lowest persona confidence is >= this value, even if personas disagree on label. Unanimous labels end it regardless. None means only unanimous labels stop early. |
Personas
Persona fields: name, role, system_prompt, model="gpt-5-mini", temperature=0.3, known_bias=None.
Classifiers (API)
All classifiers implement classify(text) and expose labels.
- FunctionClassifier:
FunctionClassifier(fn, labels)wherefnmay be sync or async - LLMClassifier:
LLMClassifier(model="gpt-5-mini", labels=None, system_prompt=None, llm_client=None, temperature=0.0).labelsmust hold at least one label. Sends a JSON schema with the labels as an enum; the returned label is matched to yours (exact, then case-insensitive). Output it cannot use returns confidence 0 (so the jury escalates it) with the reason inraw_output["error"]:invalid_json,label_not_in_labelsorinvalid_confidence. - SklearnClassifier:
SklearnClassifier(model, labels, vectorizer=None)usespredict_proba. Columns are named bymodel.classes_when those are the same set aslabels, otherwise by position; a label count that differs fromclasses_raisesValueError. Inference runs in a worker thread. - HuggingFaceClassifier:
HuggingFaceClassifier(model_name, device="cpu", labels=None)(requirestransformers). Withoutlabels, the label list comes from the model's full score list on the first call. Inference runs in a worker thread.
Judge Strategies (API)
- MajorityVoteJudge:
MajorityVoteJudge(). Confidence is the fraction of the final round's valid responses voting for the winning label. - WeightedVoteJudge:
WeightedVoteJudge(). Confidence comes from confidence-weighted label scores. - LLMJudge:
LLMJudge(model="gpt-5-mini", system_prompt=None, temperature=0.0, llm_client=None). Reads every round, the summary and each persona'sknown_bias, and answers with a label-enum JSON schema. On successjudge_strategyisllm_judgeandverdict.judge_detailsholdskey_agreements,key_disagreementsanddecisive_factor. When its call fails or its output is unusable, it returns a majority vote over the final round (llm_judge_fallback_error,llm_judge_fallback_invalid_json,llm_judge_fallback_invalid_label,llm_judge_fallback_invalid_confidence), or the primary result if that round has no valid responses. When every persona failed it skips its call and returns the primary result (llm_judge_fallback_personas_failed). - BayesianJudge:
BayesianJudge(persona_priors=None). Uses persona priors/reliability maps if provided.
Evaluating the jury
JuryEvaluator measures whether the jury beats your primary classifier on your own labelled data, and what that costs:
from llm_jury import JuryEvaluator
report = await JuryEvaluator(jury).evaluate(
texts, labels,
band_upper=0.95, # debate every item whose primary confidence is below this
max_escalations=200, # raise TooManyEscalationsError before any debate if more would run
concurrency=5,
)
report.summary() # accuracy, flips_helped / flips_hurt, debate cost, latency, confusion
report.threshold_sweep() # per threshold: escalation_rate, system_accuracy, jury_accuracy, total_cost
report.best_threshold(error_cost=10.0)
The primary classifier runs once per text. Items below band_upper go to jury.escalate(text, primary), which runs the same cost gates, debate, judge and callbacks as the escalated branch of classify; the jury's own threshold is ignored and jury.stats is not touched.
summary()keys:n,band_upper,primary_accuracy,debated,jury_accuracy_on_debated,primary_accuracy_on_debated,flips_helped(primary wrong, jury right),flips_hurt(primary right, jury wrong),debate_cost_usd,unpriced_calls,mean_debate_cost_usd,latency_ms_p50,latency_ms_p95,degraded,fallbacks,confusion(primaryover all items,juryover debated items).threshold_sweep(thresholds=None, error_cost=10.0, escalation_cost=None): at threshold t, items below t take the jury's label and the rest keep the primary label;total_cost = errors * error_cost + escalations * escalation_cost.escalation_cost=Noneuses the measured mean debate cost (0.05 when no call was priced). Thresholds aboveband_upperraiseValueError.best_threshold(error_cost=10.0, escalation_cost=None, thresholds=None): lowesttotal_cost, the lowest threshold wins a tie.items(oneEvaluationItemper text) andto_dict().
Unknown cost stays None and is never counted as 0: a debate whose calls reported no cost has jury_cost_usd=None and adds to unpriced_calls. An item's debate cost is the verdict total minus the primary cost, so a custom classifier should set cost_usd (0.0 for local models) for debate costs to be known. examples/evaluate_jury.py runs offline with a stub LLM client.
Threshold Calibration
ThresholdCalibrator(jury) then await calibrate(texts, labels, error_cost=10.0, escalation_cost=None, thresholds=None, use_jury=False).
Each text is classified once. An item escalates at threshold t when its confidence is below t or not finite, as in Jury.
use_jury=False(default) never runs the jury: each escalation costsescalation_cost(default 0.05) and counts as neither right nor wrong, soaccuracyis the primary accuracy on the items it keeps.use_jury=TruerunsJuryEvaluatorwithband_upperset to the highest threshold and picks the threshold from the measured sweep.escalation_costdefaults to the measured mean debate cost, rows also carrysystem_accuracyandjury_accuracy, and the evaluation is kept oncalibrator.evaluation_report.
Report: calibration_report() returns best_threshold, use_jury and rows with threshold, accuracy, escalation rate, and total cost (plus summary after a jury calibration). calibrate(...) mutates jury.threshold to the best threshold.
LLM Transport (LiteLLMClient)
from llm_jury import Jury
from llm_jury.llm import LiteLLMClient
client = LiteLLMClient(
timeout_seconds=60.0, # per request; None keeps litellm's default
max_attempts=3, # total attempts, the first one included
api_key=None, # passed to litellm when set
api_base=None, # passed to litellm when set
)
jury = Jury(classifier=..., personas=..., llm_client=client)
complete(model, system_prompt, prompt, temperature=0.0, response_format=None)callslitellm.acompletion- Retries connection errors, timeouts, 429 and 5xx responses, waiting for
Retry-Afterwhen the provider sends it (capped at 60 s) and backing off exponentially otherwise - Returns:
{content, tokens, cost_usd}(cost_usdisNonewhen litellm cannot price the model) - Raises a runtime error if
litellmis not installed and no customllm_clientis injected.
Response Cache (CachingLLMClient)
Opt-in LRU wrapper around any LLMClient. Keyed on
(model, system_prompt, prompt, temperature, response_format).
Successful responses only; exceptions propagate without being cached.
A hit reports cost_usd 0.0 and cached: True, so it adds nothing to
verdict totals or the cost cap.
from llm_jury import CachingLLMClient, Jury
from llm_jury.llm import LiteLLMClient
jury = Jury(
# ...
llm_client=CachingLLMClient(
LiteLLMClient(),
max_size=1000, # LRU cap
ttl_seconds=3600, # optional; omit for no expiry
),
)
hits, misses, and size are exposed for introspection. Call
clear() to drop everything. The cache is in-process and per-instance;
share the CachingLLMClient object across Jury instances if you want
a shared cache. Caches at any temperature; if you need fresh stochastic
samples, don't wrap.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Authentication/401 error on first LLM call |
OPENAI_API_KEY not set, or wrong provider key for the model |
export OPENAI_API_KEY=...; or pass llm_client=LiteLLMClient(api_key=...) |
ImportError: No module named 'litellm' |
litellm not installed and no custom llm_client injected |
pip install llm-jury-classifier (litellm is a hard dep); or inject your own llm_client |
ImportError: transformers / numpy |
Using HuggingFaceClassifier / SklearnClassifier without the extras |
pip install "llm-jury-classifier[huggingface]" or [sklearn] |
ValueError: LLMClassifier requires at least one non-empty label. |
LLMClassifier(labels=[]) or labels that are all blank |
Pass at least one label |
ValueError: confidence_threshold must be a finite number in [0, 1] |
Jury(confidence_threshold=...) outside [0, 1], NaN or not a number |
Pass a threshold between 0 and 1 |
Primary result has confidence 0.0 and the item always escalates |
LLMClassifier could not use the model's reply; primary_result.raw_output["error"] is invalid_json, label_not_in_labels or invalid_confidence |
Use a model that honours response_format JSON schemas; or wrap your own call in FunctionClassifier to control parsing |
| Repeated 429s and the whole call fails | Rate-limit budget exhausted after 3 attempts per call (the client already waits for Retry-After, up to 60 s) |
Lower debate_concurrency and batch concurrency; raise LiteLLMClient(max_attempts=...); use a higher-tier key |
verdict.judge_strategy == "cost_guard_pre_flight" (no debate ran) |
estimated_max_debate_cost_usd exceeded max_debate_cost_usd. The estimate counts the summariser and LLM judge calls too, so a cap tuned for 0.2.0 or earlier can now trip |
Raise the cap, lower max_rounds, lower estimated_cost_per_persona_usd if your calls cost less, or accept the primary classifier verdict |
verdict.judge_strategy == "cost_guard_primary_fallback" (debate ran partially) |
Spend during the debate hit the cap; calls that reported no cost are charged at estimated_cost_per_persona_usd |
Same as above; spend can still overshoot by up to one concurrency batch (in-flight calls aren't cancellable) |
verdict.judge_strategy == "cost_guard_user_override" |
Your on_cost_estimate callback returned False |
Working as intended: the debate was skipped per your policy |
verdict.judge_strategy == "llm_judge_fallback_error" |
The LLM judge call raised (after retries) | The verdict is a majority vote over the final round. Check the judge model and key; consider routing these to review |
verdict.judge_strategy is llm_judge_fallback_invalid_json, _invalid_label or _invalid_confidence |
The judge replied with unparseable JSON, a label outside your labels, or a non-numeric confidence | Same majority-vote fallback. Use a judge model that honours response_format |
verdict.judge_strategy == "llm_judge_fallback_personas_failed" |
Every persona call failed, so the judge had nothing to weigh | The primary classifier result is returned; see debate_degraded below |
verdict.total_cost_usd is None |
The model is not in litellm's pricing table, or the primary classifier reported no cost | Check litellm.model_cost; pin to a known-priced model; or compute cost yourself in a custom llm_client |
verdict.total_cost_usd looks too low |
Some calls reported no cost; verdict.debate_transcript.unpriced_calls counts them and the total is a lower bound |
Same as above |
| Verdict is never escalated even at very low confidence | personas=[] silently disables escalation (by design) |
Pass at least one persona |
verdict.debate_degraded is True |
One or more persona calls failed (auth, rate-limit exhaustion, unparseable output, a label outside your labels). Failed personas carry no vote; if the whole final round failed, judges return the primary classifier result | Inspect verdict.persona_failures and the transcript's failed responses; consider routing degraded verdicts to human review |
One persona's responses have failed=True in every round |
That persona's model is invalid or not available to your key. Its placeholders stay in the transcript and carry no vote |
Inspect logs; fix the persona's model or remove the persona |
Debate summary is None in deliberation mode |
The debate stopped early (unanimous labels or early_stop_min_confidence), or the summariser call failed (a warning is logged) |
Nothing to fix for an early stop; otherwise verify the first persona's model is reachable |
| A call hangs about 60 s, then fails | LiteLLMClient times out each request after 60 s by default and retries timeouts |
LiteLLMClient(timeout_seconds=30) |
verdict.total_duration_ms is 0 from a custom judge |
Custom judge didn't set the field; Jury only backfills when at default | Set total_duration_ms in your judge if you want a custom value |
Known problems and their status are tracked in docs/REVIEW.md.
Examples
Runnable examples in examples/ (require OPENAI_API_KEY):
python examples/content_moderation.py # Content moderation with LLM classifier
python examples/custom_personas.py # Custom persona definitions + deliberation mode
python examples/legal_compliance.py # Legal compliance with sequential debate + weighted vote
python examples/threshold_calibration.py # Threshold calibration (no API key needed)
Testing
cd packages/python
pip install -e ".[dev]"
python -m pytest tests/ -v
CLI
The CLI is for batch workflows. The primary interface is the Python API above.
# Classify a JSONL file
llm-jury classify \
--input input.jsonl \
--output verdicts.jsonl \
--classifier function \
--personas content_moderation \
--judge majority \
--threshold 0.7 \
--labels safe,unsafe
# Calibrate threshold from labelled data (never runs the jury)
llm-jury calibrate \
--input calibration.jsonl \
--classifier function \
--labels safe,unsafe
# Calibrate on measured jury outcomes (makes LLM calls)
llm-jury calibrate \
--input calibration.jsonl \
--use-jury \
--personas content_moderation \
--judge majority \
--labels safe,unsafe
# Measure the jury against the primary classifier
llm-jury eval \
--input calibration.jsonl \
--personas content_moderation \
--judge majority \
--labels safe,unsafe \
--max-escalations 200 \
--output report.json
calibrate options: --error-cost (default 10), --escalation-cost (default 0.05, or the measured mean debate cost with --use-jury), --initial-threshold (default 0.7), --use-jury. Without --use-jury the jury options have no effect, and calibrate says so on stderr when you pass any.
eval options: --output (full report with per-row results, as JSON), --band-upper (default 0.95), --max-escalations, --thresholds (comma-separated, each at most --band-upper), --error-cost (default 10), --escalation-cost (default: measured mean debate cost), --concurrency (default 5), plus the classifier, persona, judge and debate options of classify. It prints one JSON line with best_threshold, summary and sweep.
Input JSONL format for classify:
{"text": "some text", "predicted_label": "safe", "predicted_confidence": 0.95}
With --classifier function (the default) the CLI replays the stored predictions, so every row needs text, predicted_label and predicted_confidence (0 to 1); the ground-truth label field is never read as a prediction. Bad usage exits with code 2 before any LLM call: rows without predictions, out-of-range option values, or an unknown --debate-mode. classify writes a {"text", "error"} row for each input that failed and exits with code 1 only when every row failed.
Input JSONL format for calibrate and eval (requires ground-truth label):
{"text": "some text", "label": "safe", "predicted_label": "safe", "predicted_confidence": 0.95}
Supported classifier specs: function, llm:<model>, huggingface:<model>.
License
MIT
Metadata
Release files for llm-jury-classifier 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| llm_jury_classifier-0.3.0.tar.gz | 78.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| llm_jury_classifier-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 142.7 kB
Release files / llm_jury_classifier-0.3.0.tar.gz
| Download URL | llm_jury_classifier-0.3.0.tar.gz |
|---|---|
| Size | 78.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d87394c4ca297f0f764bbe9d383e052f09816e25aa11289efcf18d57ff923e13
|
|
BLAKE2b-256 checksum How to use checksums |
7d442dd62af4e8045af2098371167583795a375f3f60ea5a1696b87fc93da605
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / llm_jury_classifier-0.3.0-py3-none-any.whl
| Download URL | llm_jury_classifier-0.3.0-py3-none-any.whl |
|---|---|
| Size | 64.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
be887f6cd5890e5bf5f547735d84725e8b794e6bfd876f8dd86e74cbbb95d718
|
|
BLAKE2b-256 checksum How to use checksums |
f561a5c57df186bc1bb813910de330f8ed0d9b6e73f003d0b830bd1b21ca210f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log