ClinicaLLM-OmniBench is a lightweight benchmark framework for medical exam and medical specialty question-bank evaluation.
This repository is a refactor and upgrade of an older internal project. Instead of continuing to extend patch-based legacy scripts, the current version rebuilds the workflow around a cleaner evaluation skeleton with a stronger emphasis on readability, maintainability, and resumable execution.
To keep the repository clean and easier to publish, the legacy old/ reference directory has already been removed. The current workflow does not depend on the previous implementation, and future maintenance should follow the new structure described in this repository.
ClinicaLLM-OmniBench/
README.md
main.py
ollama_pull_llm.py
dataset/
general/
2012/
attending/
senior/
...
2026/
attending/
senior/
result/
general/
2012/
attending/
senior/
...
2026/
attending/
senior/
docs/
PRD.md
DOC_TREE.md
REFACTORING_GOALS.md
models/
ollama_models.json
model_modes.example.json
src/
dataset_utils.py
judge_utils.py
model_utils.py
provider_utils.py
result_utils.py
runner_utils.py
dataset/ stores the source question files. The directory is organized in two groups:
general/Stores datasets that are not managed by year, such asunited_states_medical_licensing_exam.json,kidney_disease_specialty_questions.json, andhealth_classification.json.2012/through2026/Each year contains:attending/senior/
Each dataset JSON is expected to follow a unified document structure:
{
"dataset_meta": {
"dataset_name": "Internal Medicine",
"year": 2025,
"subject": "internal_medicine",
"title_level": "attending",
"source_note": "To be filled: source note",
"license_note": "To be filled: license and usage note",
"usage_note": "To be filled: usage scope note",
"maintenance_note": "To be filled: maintenance note"
},
"items": [
{
"question_id": "internal_medicine_000001",
"question_text": "...",
"options": {
"A": "...",
"B": "..."
},
"answer": "A",
"model_results": {}
}
]
}If a legacy dataset still uses a raw list[question] format, the runtime automatically wraps it into the unified document format.
result/ mirrors dataset/ exactly.
For example:
dataset/2025/attending/internal_medicine.jsonresult/2025/attending/internal_medicine.json
The difference is that result/ appends model outputs into the model_results field of each question instead of splitting one output file per model.
Example:
{
"question_id": "internal_medicine_000001",
"question_text": "...",
"answer": "A",
"model_results": {
"qwen3_8b": {
"provider": "ollama",
"model": "qwen3:8b",
"raw_text": "A",
"normalized_answer": "A",
"status": "ok",
"judge": {
"is_correct": true,
"score": 1,
"judge_method": "rule_based_v1"
}
}
}
}This is the primary model registry used by the benchmark.
Each model entry contains:
model_idmodel_keyproviderfamilysizemodeenabled
Notes:
model_idis the request-time model name, for exampleqwen3:8b.model_keyis the stable underscore-style field name used inside result JSON files, for exampleqwen3_8b.modecurrently supportsthinkandnon_think.- Models with
enabled=truerun by default when--modelsis not specified.
This is an optional example file, not the primary configuration.
Its purpose is to show how model mode overrides can be declared separately if the project later decides to externalize mode control more aggressively. The current runtime still prefers the mode field inside models/ollama_models.json, and the example file can be missing without breaking execution.
This script prints an Ollama pull list derived from models/ollama_models.json.
Behavior:
- Prints
ollama pull ...commands grouped bythinkandnon_think - Uses model-family comments to keep the output readable
- Does not download anything automatically
- Leaves the actual download decision to the user
Because many listed models are large, you should review the output first and then selectively run only the commands you really want. This is intentional and helps avoid filling up disk space by accident.
Example:
python3 ollama_pull_llm.pyIf you want a text file copy of the generated output:
python3 ollama_pull_llm.py > ollama_pull_llm.txtThe project currently supports three provider presets:
ollamaopenroutervllm
All three are routed through the same OpenAI-compatible request adapter.
That means switching providers should mainly require changing:
--provider--base-url--api-key
The benchmark flow itself should not need a rewrite for that change.
Provider-specific default environment variables:
ollamaOLLAMA_HOSTOLLAMA_BASE_URLOLLAMA_API_KEY
openrouterOPENROUTER_BASE_URLOPENROUTER_API_KEY
vllmVLLM_HOSTVLLM_BASE_URLVLLM_API_KEY
Shared benchmark environment variables:
BENCHMARK_PROVIDERBENCHMARK_JUDGE_MODEL
python3 main.pyRecommended default command for the current Ollama-based setup:
python3 main.py \
--provider ollama \
--base-url http://localhost:11434/v1 \
--api-key ollama \
--judge-model gpt-oss:120bExample command for OpenRouter:
python3 main.py \
--provider openrouter \
--judge-model gpt-oss:120bExample command for vLLM:
python3 main.py \
--provider vllm \
--base-url http://localhost:8000/v1 \
--api-key EMPTY \
--judge-model gpt-oss:120bFor Ollama and vLLM, the default host is localhost. You can keep local development simple with the default .env, or point the benchmark to a different machine by changing either:
OLLAMA_HOST/VLLM_HOST- or the full
OLLAMA_BASE_URL/VLLM_BASE_URL
Examples for remote servers:
OLLAMA_HOST=10.0.0.25 python3 main.py --provider ollamaVLLM_HOST=10.0.0.30 python3 main.py --provider vllm --api-key EMPTYIf you already know the full endpoint path, setting the full base URL still wins:
OLLAMA_BASE_URL=http://10.0.0.25:11434/v1 python3 main.py --provider ollamaDefault behavior:
- Scan
dataset/ - Load all models with
enabled=truefrommodels/ollama_models.json - Write results into
result/ - Judge answers through the same provider, preferring
gpt-oss:120bbefore falling back to regex/rule-based comparison
python3 main.py --models qwen3:8b,deepseek-r1:8bpython3 main.py \
--base-url http://localhost:11434/v1 \
--api-key ollama \
--provider ollamapython3 main.py --limit 100python3 main.py --judge-model reThe current implementation uses LLM-first judging with rule-based fallback.
- By default, the runner uses the current provider to call
gpt-oss:120bas the judge model. - The judge model compares the expected answer against the raw model response and must answer only
TRUEorFALSE. - If the judge model is unavailable, returns invalid output, or is explicitly disabled, the system falls back to local regex/rule-based judging.
- Multiple-choice fallback logic extracts uppercase option letters such as
AorB,C. - Health classification fallback logic extracts
POSITIVEorNEGATIVE. - If
--judge-modelis set to an empty string,re, orregex, the runner skips LLM judging and uses rule-based judging only. - The judge model uses the same configured provider endpoint as the answer model. This keeps local Ollama-based setups and other OpenAI-compatible deployments operationally consistent.
The judge writes:
is_correctscorejudge_methodreason
Depending on the path taken, judge_method will look like one of these:
llm_judge_gpt_oss_120brule_based_v1rule_based_fallback_v1
The runtime resolves configuration in this order:
- Explicit CLI arguments win first
- Environment variables provide defaults second
- Registry files provide model metadata and mode behavior after the CLI has selected the relevant config paths
Examples:
--base-urloverridesOLLAMA_BASE_URLorOPENAI_BASE_URL--api-keyoverridesOLLAMA_API_KEYorOPENAI_API_KEY--judge-modeloverridesBENCHMARK_JUDGE_MODEL--provideroverridesBENCHMARK_PROVIDER
When --provider is selected but --base-url / --api-key are omitted, the runner uses provider-specific environment defaults:
ollama->OLLAMA_BASE_URL, otherwisehttp://{OLLAMA_HOST}:11434/v1, plusOLLAMA_API_KEYopenrouter->OPENROUTER_BASE_URL,OPENROUTER_API_KEYvllm->VLLM_BASE_URL, otherwisehttp://{VLLM_HOST}:8000/v1, plusVLLM_API_KEY
The current runner is intentionally fail-fast for provider request errors during answer generation. If the main provider call fails, the run stops instead of silently continuing with partially missing outputs. Judge-model failures are treated differently: they fall back to local regex/rule-based judging so the benchmark can remain runnable when the preferred judge model is not installed or does not follow the required TRUE / FALSE contract.
The repository includes three separate smoke-test scripts under tests/:
tests/test_ollama_hello.pytests/test_openrouter_hello.pytests/test_vllm_hello.py
Each script sends a simple hello request to the provider using gpt-oss:20b and prints the returned text. They are intended as connectivity checks, not as benchmark-quality evaluations.
The current defaults are:
thinkmodels:max_tokens = 131072non_thinkmodels:max_tokens = 4096
Each question keeps:
raw_textnormalized_answerjudge
This makes it possible to review whether the model answered correctly and whether it followed the required output format.
The project resumes at the question level.
If a question already contains a valid result for a given model, that question-model pair is skipped on the next run instead of being requested again.
Result files are written through a same-directory temporary file flow:
- Write
*.temp.json - Atomically replace the target
*.json
This reduces the chance of corrupting the main result file if a run is interrupted.
The current version already covers:
- Standard dataset directory scanning
- Model registration and selection
- OpenAI-compatible provider requests
- Rule-based answer normalization
- Question-level judging
- Incremental result write-back
- Resume support
The current version does not yet cover:
- Concurrent scheduling
- A richer multi-provider registry
- More advanced free-text judging
- Exported benchmark summary reports
If you keep extending this repository, the safest conventions are:
dataset/manages source questions onlyresult/manages outputs only and should not overwrite source question filesmodels/manages model registry data only- Directory names, file names, JSON field names, and placeholder text should stay in English
- Documentation, comments, and top-level explanations should remain explicit enough that external readers do not need to infer benchmark behavior from the code alone