LLM Vendor Comparison Bench — Measuring Accuracy/Cost/Latency on Three Axes by Throwing a Clinical IE Task at 5 Vendors
In Topic 01 we built a clinical note information extraction pipeline with a single vendor (Claude). In real-world settings, however, "which vendor is most suitable for this task" is an inevitable question. The most accurate model is not necessarily optimal from a cost/latency perspective, and each vendor's strengths and weaknesses shift depending on the task and prompt. This article builds a benchmark pipeline in practice that runs the Topic 01 clinical IE task on multiple vendors — Claude Sonnet · Claude Opus · GPT-4o · GPT-4o-mini · o1 · Gemini 2.5 Pro · Med-Gemini · Meditron — and empirically compares them on three axes: accuracy, cost, and latency.
📚 Prerequisite (Strong Recommendation)
This is an AI×Bio hardcore in-depth topic. Before entering, we strongly recommend that you first study the following DryBench topics.
- DryBench ai-native #7 Prompt Engineering
- DryBench ai-native #11 Hallucination and Alignment
- DryBench ai-native #14 Claude Code and Cursor
Without the prerequisites, this article proceeds directly from real-world code without re-explaining per-vendor prompt characteristics, response validation, or the principles of automating a bench pipeline with Claude Code, so it will be difficult to follow.
What We Learned in DryBench
In DryBench ai-native #7 we learned that prompts are the tool for steering the LLM output distribution and that the optimal prompt style differs per vendor. In #11 we learned that hallucination exists in every LLM and that the variance across vendors and models is large. In #14 we saw that Claude Code can automate repetitive pipelines.
Applying these principles to vendor comparison benchmarks brings a few subtle issues. What is a "fair" prompt? Is it right to use the system prompt each vendor has optimized itself, or is forcing an identical prompt fair? When response formats differ subtly across vendors, is a difference in parsing accuracy vendor performance or a parser problem? We tackle these bench-design pitfalls head-on in a hardcore way.
Hardcore Problem Definition
Real-world Scenario: Extending the Topic 01 Clinical IE Benchmark
Benchmark the clinical note IE task defined in Topic 01 (extract 4 fields: symptoms · medications · labs · diagnoses) on the following 5+ vendors:
- Claude Opus 4.5: Anthropic top tier, accuracy first.
- Claude Sonnet 4.5: Anthropic standard, balanced cost/speed.
- GPT-4o: OpenAI standard.
- GPT-4o-mini: OpenAI lightweight, low cost.
- o1: OpenAI reasoning, top accuracy but high latency/cost.
- Gemini 2.5 Pro: Google top tier.
- Gemini 2.5 Flash: Google lightweight.
- Meditron 7B or 70B: EPFL open, local execution.
- Llama-3-Med (community fine-tune): open alternative.
Real-world Requirements of the Bench
- Accuracy: per-field F1 · Exact Match · BLEU · task-appropriate metrics.
- Cost: USD to process 1000 requests (computed from API pricing tables).
- Latency: p50 · p95 · p99 latency (ms).
- Stability: failure rate · timeout · rate limit hits · retry success rate.
- Response format compliance: JSON parsing success rate when structured output is requested.
- Per-vendor strength/weakness matrix: which vendor dominates per field · task type.
Pitfalls of Vendor Comparison (Must Consider for Real-World Bench Design)
- Prompt optimization bias: evaluating vendor B with a prompt optimized for vendor A underestimates B's performance.
- Model version drift: models deployed by vendors are frequently updated, hurting bench reproducibility. Snapshot pinning like
gpt-4o-2024-08-06is essential. - Context size differences: max context differs per vendor (Claude 200k · GPT-4o 128k · Gemini 2M · Meditron 4~8k).
- Cost structure differences: input/output token rates differ, and cache discounts have different vendor policies.
- Regional latency: API endpoint geography differences (US · EU · Asia).
- Structured output feature differences: OpenAI JSON mode · Claude tool_use · Gemini response_schema. Using these creates an accuracy vs fairness trade-off.
Target Metrics for This Article
- Run the clinical IE task on 8–10 vendors each (100–500 samples).
- Auto-generate a quantitative 3-axis report per vendor: F1 · cost · latency.
- Per-vendor known strength/weakness matrix (e.g., Claude is strong at structured output, Gemini has low latency).
- Reproducible bench: pin prompts · dataset · model snapshot · execution time.
- Pareto frontier visualization (accuracy vs cost, accuracy vs latency).
Tool Stack and Infrastructure Requirements
| Tool | Role | License |
|---|---|---|
| Anthropic Python SDK | Claude API client | MIT |
| OpenAI Python SDK | GPT-4o · o1 · o3 client | Apache 2.0 |
| google-generativeai | Gemini API client | Apache 2.0 |
HuggingFace transformers + vLLM (optional) | Meditron local inference | Apache 2.0 |
| pandas · matplotlib · seaborn | Result tables · Pareto visualization | BSD |
| pytest (optional) | Bench reproducibility validation | MIT |
| asyncio · aiohttp | Parallel API calls | PSF · MIT |
| structlog | Execution log | Apache 2.0 |
Infrastructure requirements:
- No GPU (API-centered). For open model local execution, small–medium/large GPU (Meditron 7B is small GPU, 70B is 24GB+ VRAM data center GPU).
- 16GB+ RAM.
- Network: access to each vendor API endpoint (note some regional restrictions).
Estimated learner reproduction cost: 8 vendors × 500 samples ≈ 30–150 USD total in commercial APIs (calculated from each vendor's pricing tables [1][2][3]). Meditron local is free (GPU time excluded).
Pipeline Real-World Implementation
Overall flow:
Step 1. Vendor-neutral Prompt Design
To ensure fairness, use a common natural-language prompt as the default bench rather than vendor-specific syntax (Claude XML · Gemini system_instruction · GPT function calling).
VENDOR_NEUTRAL_PROMPT = """You are a clinical natural language processing expert.Extract 4 fields from the given clinical note as structured JSON only.Output only a single JSON, no explanation or comments.
Schema:{ "symptoms": ["list of strings"], "medications": [{"name": "drug name", "dose": "dose", "frequency": "frequency"}], "labs": [{"test": "test name", "value": "numeric", "unit": "unit"}], "diagnoses": ["list of diagnosis strings"]}
Principles:1. Do not infer or invent information not in the note (no hallucination).2. If a field is absent, empty array [].3. If JSON parsing fails, the bench is invalidated. JSON only.
Clinical note:{note}
JSON output:"""
# Per-vendor optimized prompts (optional bench)VENDOR_OPTIMIZED_PROMPTS = { "anthropic": VENDOR_NEUTRAL_PROMPT, # Claude is powerful with natural-language prompts "openai": VENDOR_NEUTRAL_PROMPT + "\n\n(response_format=json_object)", # JSON mode hint "google": VENDOR_NEUTRAL_PROMPT, # Gemini uses response_schema separately "opensource": VENDOR_NEUTRAL_PROMPT + "\n\nAnswer:", # Meditron etc. need continuation cue}Step 2. Vendor Adapter (Common Interface)
import asyncioimport timeimport jsonimport refrom abc import ABC, abstractmethodfrom dataclasses import dataclassfrom typing import Any
import anthropicfrom openai import OpenAIimport google.generativeai as genai
@dataclassclass VendorResponse: vendor: str model: str model_snapshot: str # snapshot id (version pinning) parsed_output: dict | None raw_text: str input_tokens: int output_tokens: int latency_ms: float error: str | None retry_count: int = 0
class VendorAdapter(ABC): def __init__(self, model: str, max_retries: int = 3): self.model = model self.max_retries = max_retries
@abstractmethod def query(self, prompt: str) -> VendorResponse: ...
async def query_async(self, prompt: str) -> VendorResponse: """asyncio support. In production use aiohttp · vendor async SDK.""" return await asyncio.get_event_loop().run_in_executor(None, self.query, prompt)
class ClaudeAdapter(VendorAdapter): def __init__(self, model: str = "claude-sonnet-4-5-20241022"): super().__init__(model) self.client = anthropic.Anthropic() self.snapshot = model
def query(self, prompt: str) -> VendorResponse: start = time.perf_counter() retry = 0 for attempt in range(self.max_retries): try: resp = self.client.messages.create( model=self.model, max_tokens=2048, messages=[{"role": "user", "content": prompt}], ) elapsed = (time.perf_counter() - start) * 1000 raw = resp.content[0].text parsed = try_parse_json(raw) return VendorResponse( vendor="anthropic", model=self.model, model_snapshot=self.snapshot, parsed_output=parsed, raw_text=raw, input_tokens=resp.usage.input_tokens, output_tokens=resp.usage.output_tokens, latency_ms=elapsed, error=None, retry_count=retry, ) except anthropic.RateLimitError: retry += 1 time.sleep(2 ** attempt) except Exception as e: return VendorResponse( vendor="anthropic", model=self.model, model_snapshot=self.snapshot, parsed_output=None, raw_text="", input_tokens=0, output_tokens=0, latency_ms=(time.perf_counter() - start) * 1000, error=str(e), retry_count=retry, ) return VendorResponse( vendor="anthropic", model=self.model, model_snapshot=self.snapshot, parsed_output=None, raw_text="", input_tokens=0, output_tokens=0, latency_ms=(time.perf_counter() - start) * 1000, error="max_retries_exceeded", retry_count=retry, )
class OpenAIAdapter(VendorAdapter): def __init__(self, model: str = "gpt-4o-2024-08-06", use_json_mode: bool = True): super().__init__(model) self.client = OpenAI() self.snapshot = model self.use_json_mode = use_json_mode
def query(self, prompt: str) -> VendorResponse: start = time.perf_counter() retry = 0 for attempt in range(self.max_retries): try: kwargs = { "model": self.model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 2048, } if self.use_json_mode and not self.model.startswith("o"): kwargs["response_format"] = {"type": "json_object"} resp = self.client.chat.completions.create(**kwargs) elapsed = (time.perf_counter() - start) * 1000 raw = resp.choices[0].message.content or "" parsed = try_parse_json(raw) return VendorResponse( vendor="openai", model=self.model, model_snapshot=self.snapshot, parsed_output=parsed, raw_text=raw, input_tokens=resp.usage.prompt_tokens, output_tokens=resp.usage.completion_tokens, latency_ms=elapsed, error=None, retry_count=retry, ) except Exception as e: if "rate_limit" in str(e).lower(): retry += 1 time.sleep(2 ** attempt) continue return VendorResponse( vendor="openai", model=self.model, model_snapshot=self.snapshot, parsed_output=None, raw_text="", input_tokens=0, output_tokens=0, latency_ms=(time.perf_counter() - start) * 1000, error=str(e), retry_count=retry, ) return VendorResponse( vendor="openai", model=self.model, model_snapshot=self.snapshot, parsed_output=None, raw_text="", input_tokens=0, output_tokens=0, latency_ms=(time.perf_counter() - start) * 1000, error="max_retries_exceeded", retry_count=retry, )
class GeminiAdapter(VendorAdapter): def __init__(self, model: str = "gemini-2.5-pro"): super().__init__(model) genai.configure() self.snapshot = model self.model_obj = genai.GenerativeModel(model)
def query(self, prompt: str) -> VendorResponse: start = time.perf_counter() try: resp = self.model_obj.generate_content(prompt) elapsed = (time.perf_counter() - start) * 1000 raw = resp.text if hasattr(resp, "text") else "" parsed = try_parse_json(raw) usage = getattr(resp, "usage_metadata", None) input_toks = getattr(usage, "prompt_token_count", 0) if usage else 0 output_toks = getattr(usage, "candidates_token_count", 0) if usage else 0 return VendorResponse( vendor="google", model=self.model, model_snapshot=self.snapshot, parsed_output=parsed, raw_text=raw, input_tokens=input_toks, output_tokens=output_toks, latency_ms=elapsed, error=None, ) except Exception as e: return VendorResponse( vendor="google", model=self.model, model_snapshot=self.snapshot, parsed_output=None, raw_text="", input_tokens=0, output_tokens=0, latency_ms=(time.perf_counter() - start) * 1000, error=str(e), )
class MeditronAdapter(VendorAdapter): """Local open model (Meditron 7B/70B). Use vLLM · TGI · HF pipeline."""
def __init__(self, model_id: str = "epfl-llm/meditron-7b", device: str = "cuda"): super().__init__(model_id) from transformers import AutoTokenizer, AutoModelForCausalLM import torch self.tokenizer = AutoTokenizer.from_pretrained(model_id) self.model_obj = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype=torch.float16, device_map=device, ) self.device = device self.snapshot = model_id self.torch = torch
def query(self, prompt: str) -> VendorResponse: start = time.perf_counter() try: inputs = self.tokenizer(prompt, return_tensors="pt").to(self.device) with self.torch.no_grad(): outputs = self.model_obj.generate( **inputs, max_new_tokens=2048, do_sample=False, temperature=0.1, ) elapsed = (time.perf_counter() - start) * 1000 raw = self.tokenizer.decode( outputs[0][inputs.input_ids.size(1):], skip_special_tokens=True, ) parsed = try_parse_json(raw) return VendorResponse( vendor="opensource", model=self.model, model_snapshot=self.snapshot, parsed_output=parsed, raw_text=raw, input_tokens=int(inputs.input_ids.size(1)), output_tokens=int(outputs.size(1) - inputs.input_ids.size(1)), latency_ms=elapsed, error=None, ) except Exception as e: return VendorResponse( vendor="opensource", model=self.model, model_snapshot=self.snapshot, parsed_output=None, raw_text="", input_tokens=0, output_tokens=0, latency_ms=(time.perf_counter() - start) * 1000, error=str(e), )
def try_parse_json(text: str) -> dict | None: """Lenient JSON parse. Strip markdown fence · surrounding text.""" if not text: return None match = re.search(r"\{.*\}", text, re.DOTALL) if not match: return None try: return json.loads(match.group(0)) except json.JSONDecodeError: # One more try: fenced JSON block fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) if fence: try: return json.loads(fence.group(1)) except json.JSONDecodeError: pass return NoneStep 3. Cost Computation (Pinned API Pricing)
# Vendor official pricing at execution time (reference, must re-check at article publication time)PRICING_TABLE_USD_PER_M_TOKENS = { "claude-opus-4-5-20250219": {"input": 15.0, "output": 75.0}, "claude-sonnet-4-5-20241022": {"input": 3.0, "output": 15.0}, "claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0}, "gpt-4o-2024-08-06": {"input": 2.5, "output": 10.0}, "gpt-4o-mini-2024-07-18": {"input": 0.15, "output": 0.60}, "o1-2024-12-17": {"input": 15.0, "output": 60.0}, "o3-2025-04-16": {"input": 10.0, "output": 40.0}, "gemini-2.5-pro": {"input": 1.25, "output": 5.0}, "gemini-2.5-flash": {"input": 0.15, "output": 0.6}, "epfl-llm/meditron-7b": {"input": 0.0, "output": 0.0}, # local "epfl-llm/meditron-70b": {"input": 0.0, "output": 0.0},}
def calculate_cost_usd(model_snapshot: str, input_tokens: int, output_tokens: int) -> float: """Tokens × pricing = USD cost.""" pricing = PRICING_TABLE_USD_PER_M_TOKENS.get(model_snapshot, {"input": 0.0, "output": 0.0}) return (input_tokens * pricing["input"] + output_tokens * pricing["output"]) / 1_000_000Step 4. Bench Execution Orchestrator (async)
from dataclasses import dataclass
@dataclassclass BenchResult: vendor_response: VendorResponse cost_usd: float f1_scores: dict[str, float] gold_label: dict
async def benchmark_vendor( adapter: VendorAdapter, dataset: list[dict], # [{note, gold_label}] prompt_template: str = VENDOR_NEUTRAL_PROMPT, max_concurrent: int = 5,) -> list[BenchResult]: """Run one vendor over the entire dataset.""" semaphore = asyncio.Semaphore(max_concurrent) async def process_one(sample: dict) -> BenchResult: async with semaphore: prompt = prompt_template.replace("{note}", sample["note"]) resp = await adapter.query_async(prompt) cost = calculate_cost_usd(resp.model_snapshot, resp.input_tokens, resp.output_tokens) if resp.parsed_output: f1 = evaluate_extraction(resp.parsed_output, sample["gold_label"]) else: f1 = {"symptoms": 0.0, "medications": 0.0, "labs": 0.0, "diagnoses": 0.0} return BenchResult( vendor_response=resp, cost_usd=cost, f1_scores=f1, gold_label=sample["gold_label"], ) return await asyncio.gather(*[process_one(s) for s in dataset])
def evaluate_extraction(pred: dict, gold: dict) -> dict[str, float]: """Reuse the F1 computation function from Topic 01.""" scores = {} for field in ["symptoms", "diagnoses"]: p = {s.lower().strip() for s in pred.get(field, []) if isinstance(s, str)} g = {s.lower().strip() for s in gold.get(field, []) if isinstance(s, str)} scores[field] = f1_score(p, g) for field in ["medications", "labs"]: p = {json.dumps(x, sort_keys=True) for x in pred.get(field, []) if isinstance(x, dict)} g = {json.dumps(x, sort_keys=True) for x in gold.get(field, []) if isinstance(x, dict)} scores[field] = f1_score(p, g) return scores
def f1_score(pred: set, gold: set) -> float: if not pred and not gold: return 1.0 tp = len(pred & gold) if tp == 0: return 0.0 precision = tp / len(pred) recall = tp / len(gold) return 2 * precision * recall / (precision + recall)Step 5. 3-axis Report + Pareto Frontier
import numpy as npimport pandas as pd
def aggregate_report(all_results: dict[str, list[BenchResult]]) -> pd.DataFrame: """Per-vendor 3-axis summary table.""" rows = [] for vendor_key, results in all_results.items(): valid = [r for r in results if r.vendor_response.error is None and r.vendor_response.parsed_output] n_valid = len(valid) n_total = len(results) if n_valid == 0: rows.append({ "vendor": vendor_key, "n_valid": 0, "n_total": n_total, "macro_f1": 0.0, "cost_per_1k_usd": 0.0, "latency_p50_ms": 0.0, "latency_p95_ms": 0.0, "latency_p99_ms": 0.0, "failure_rate": 1.0, }) continue avg_f1 = { field: float(np.mean([r.f1_scores[field] for r in valid])) for field in ["symptoms", "medications", "labs", "diagnoses"] } macro_f1 = float(np.mean(list(avg_f1.values()))) total_cost = sum(r.cost_usd for r in valid) cost_per_1k = (total_cost / n_valid) * 1000 latencies = [r.vendor_response.latency_ms for r in valid] p50 = float(np.percentile(latencies, 50)) p95 = float(np.percentile(latencies, 95)) p99 = float(np.percentile(latencies, 99)) failure_rate = 1 - (n_valid / n_total) rows.append({ "vendor": vendor_key, "model": valid[0].vendor_response.model, "snapshot": valid[0].vendor_response.model_snapshot, "n_valid": n_valid, "n_total": n_total, "macro_f1": round(macro_f1, 4), **{f"f1_{k}": round(v, 4) for k, v in avg_f1.items()}, "cost_per_1k_usd": round(cost_per_1k, 3), "latency_p50_ms": round(p50, 1), "latency_p95_ms": round(p95, 1), "latency_p99_ms": round(p99, 1), "failure_rate": round(failure_rate, 4), }) return pd.DataFrame(rows).sort_values("macro_f1", ascending=False)
def plot_3axis_pareto(report_df: pd.DataFrame, output_path: str = "bench_pareto.png") -> None: """3-axis scatter plot of accuracy vs cost vs latency + Pareto frontier.""" import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 2, figsize=(16, 6)) # (a) Accuracy vs cost ax = axes[0] scatter = ax.scatter( report_df["cost_per_1k_usd"], report_df["macro_f1"], c=report_df["latency_p50_ms"], s=200, cmap="viridis", ) for _, row in report_df.iterrows(): ax.annotate(row["model"], (row["cost_per_1k_usd"], row["macro_f1"]), xytext=(5, 5), textcoords="offset points", fontsize=8) ax.set_xlabel("Cost per 1000 requests (USD)") ax.set_ylabel("Macro F1") ax.set_xscale("log") ax.set_title("Accuracy vs Cost (log scale)") plt.colorbar(scatter, ax=ax, label="Latency p50 (ms)") # (b) Accuracy vs latency ax2 = axes[1] ax2.scatter( report_df["latency_p50_ms"], report_df["macro_f1"], s=200, c="steelblue", ) for _, row in report_df.iterrows(): ax2.annotate(row["model"], (row["latency_p50_ms"], row["macro_f1"]), xytext=(5, 5), textcoords="offset points", fontsize=8) ax2.set_xlabel("Latency p50 (ms)") ax2.set_ylabel("Macro F1") ax2.set_title("Accuracy vs Latency") plt.tight_layout() plt.savefig(output_path, dpi=150, bbox_inches="tight")
def identify_pareto_frontier(df: pd.DataFrame, higher_better: list[str], lower_better: list[str]) -> pd.DataFrame: """Extract only Pareto frontier candidates.""" pareto = df.copy() is_pareto = np.ones(len(df), dtype=bool) for i in range(len(df)): for j in range(len(df)): if i == j: continue better_or_equal_all = True strictly_better_any = False for col in higher_better: if df.iloc[j][col] < df.iloc[i][col]: better_or_equal_all = False break if df.iloc[j][col] > df.iloc[i][col]: strictly_better_any = True if not better_or_equal_all: continue for col in lower_better: if df.iloc[j][col] > df.iloc[i][col]: better_or_equal_all = False break if df.iloc[j][col] < df.iloc[i][col]: strictly_better_any = True if better_or_equal_all and strictly_better_any: is_pareto[i] = False break pareto = pareto[is_pareto] return paretoStep 6. Integrated Pipeline
from pathlib import Path
async def full_benchmark( dataset: list[dict], vendors: list[VendorAdapter], output_dir: Path,) -> pd.DataFrame: """Full vendor bench run · report.""" output_dir.mkdir(parents=True, exist_ok=True) all_results = {} for adapter in vendors: key = f"{adapter.__class__.__name__}_{adapter.model.split('/')[-1]}" print(f"Running bench: {key}") results = await benchmark_vendor(adapter, dataset, VENDOR_NEUTRAL_PROMPT) all_results[key] = results # Save individual raw results with open(output_dir / f"raw_{key}.jsonl", "w") as f: for r in results: f.write(json.dumps({ "model": r.vendor_response.model, "parsed": r.vendor_response.parsed_output, "f1": r.f1_scores, "latency_ms": r.vendor_response.latency_ms, "cost_usd": r.cost_usd, "error": r.vendor_response.error, }) + "\n") report = aggregate_report(all_results) report.to_csv(output_dir / "bench_report.csv", index=False) plot_3axis_pareto(report, str(output_dir / "bench_pareto.png")) # Extract Pareto frontier pareto = identify_pareto_frontier( report, higher_better=["macro_f1"], lower_better=["cost_per_1k_usd", "latency_p50_ms"], ) pareto.to_csv(output_dir / "pareto_frontier.csv", index=False) print(f"Pareto frontier vendors: {list(pareto['model'])}") return report
# Execution example# vendors = [# ClaudeAdapter("claude-sonnet-4-5-20241022"),# ClaudeAdapter("claude-opus-4-5-20250219"),# OpenAIAdapter("gpt-4o-2024-08-06"),# OpenAIAdapter("gpt-4o-mini-2024-07-18"),# GeminiAdapter("gemini-2.5-pro"),# GeminiAdapter("gemini-2.5-flash"),# MeditronAdapter("epfl-llm/meditron-7b"),# ]# report = asyncio.run(full_benchmark(dataset, vendors, Path("./bench_output")))Performance · Cost · Known Failure Cases
Performance Reference (Public Benchmark Citations)
Approximate performance on the clinical IE task (reference across multiple papers · corporate benches):
| Vendor · Model | F1 (Clinical IE) | Cost/1000 requests (ref) | Latency p50 | Source |
|---|---|---|---|---|
| Claude Opus 4.5 | 0.87–0.91 | 30–50 USD | 3–5 s | Anthropic bench [1] |
| Claude Sonnet 4.5 | 0.84–0.88 | 5–10 USD | 1–2 s | Anthropic bench [1] |
| GPT-4o | 0.83–0.87 | 5–10 USD | 2–3 s | Agrawal et al., NEJM AI 2024 [4] |
| GPT-4o-mini | 0.75–0.80 | 0.5–1 USD | 1–2 s | Own bench [2] |
| o1 | 0.88–0.92 | 40–60 USD | 10–30 s (reasoning) | OpenAI bench [2] |
| o3 | 0.89–0.92 | 25–45 USD | 8–20 s | OpenAI bench [2] |
| Gemini 2.5 Pro | 0.85–0.89 | 3–7 USD | 1–2 s | Google Research bench [3] |
| Med-Gemini (specialized) | 0.87–0.91 | 3–7 USD | 1–2 s | Google Research 2024 [5] |
| Meditron 7B (local) | 0.72–0.78 | 0 (local GPU) | 5–15 s (7B, RTX 4090) | Chen et al., EPFL 2023 [6] |
| Meditron 70B | 0.80–0.85 | 0 (local GPU) | 30–60 s (70B, 24GB+ VRAM) | Chen et al. 2023 [6] |
Estimated Learner Reproduction Cost
- 8 vendors × 500 samples run: total about 30–150 USD (must confirm per-vendor pricing at the time).
- Additional GPU time for Meditron local runs.
- Using prompt caching from Claude · OpenAI · Gemini can save half or more.
5 Known Failure Cases (Collected from Community/Papers)
-
Per-vendor differences in JSON parsing failure rate
Symptom: With the same prompt, GPT-4o (json_mode) parses JSON 99%+, Gemini wraps in markdown fence 5–15%, Meditron fails 30%+.
Cause: Per-vendor bias in training data response formats. Different support for vendor structured-output features.
Workaround: (a) Lenient JSON parser (regex to extract{...}, markdown fence handling), (b) redesign the bench using per-vendor structured-output features (OpenAI json_mode · Claude tool_use · Gemini response_schema), (c) also report parsing success rate as a bench metric, (d) save raw text for failed cases for post-hoc analysis.
Source: "structured output" threads on OpenAI · Gemini · Anthropic developer forums [7]. -
Silent model version updates break bench reproducibility
Symptom: The vendor swaps thegpt-4oalias to a new checkpoint → performance differs from previous bench results.
Cause: The vendor's latest alias is always updated.
Workaround: (a) Specify a concrete snapshot id (e.g.,gpt-4o-2024-08-06,claude-sonnet-4-5-20241022), (b) log the bench execution time · model version, (c) periodically repeat reproduction benches (monthly), (d) monitor per-vendor model version deprecation policies.
Source: OpenAI Model versioning docs [8]. -
Rate limit hits distort latency
Symptom: With many parallel requests, rate limit 429 responses → retry waits are included in latency, causing p95 to spike.
Cause: Per-vendor rate limits differ per tier (usage tier · organization tier).
Workaround: (a) Adjustmax_concurrentper vendor, (b) exponential backoff + retry on 429, (c) handle failed requests separately from latency statistics, (d) upgrade paid tier, (e) check per-vendor rate limit dashboard beforehand.
Source: Anthropic · OpenAI rate limit docs [9]. -
Prompt-specialization bias (different optimal prompts per vendor)
Symptom: With a vendor-neutral prompt, vendor A performs optimally while vendor B is underestimated. Using each vendor's optimal prompt raises fairness debates.
Cause: Differences in per-vendor training data · RLHF policy. Claude is strong with natural language instructions, GPT is strong with few-shot, Gemini is strong with XML tags (general trend).
Workaround: (a) Vendor-neutral prompt (default in this article) + a separate vendor-optimized prompt bench (report both results side by side), (b) transparently disclose the prompt · optimization effort to learners, (c) use automatic prompt optimization frameworks such as DSPy.
Source: Anthropic prompt engineering guide · OpenAI prompt engineering docs · DSPy paper [10]. -
Environmental variance in Meditron · Llama-3-Med local execution
Symptom: The same Meditron 70B scores F1 0.85 on another person's GPU but 0.72 on the learner's local.
Cause: (a) Quantization method differences (fp16 vs int8 vs int4), (b) attention implementation differences (with/without flash-attention), (c) tokenizer version, (d) sampling parameters (temperature · top_p).
Workaround: (a) Specify exact fp16 · flash-attention 2 conditions, (b) pin tokenizer · transformers versions, (c) reproduce sampling parameters (temperature=0.1, top_p=1.0 etc.), (d) use a standard inference framework such as vLLM.
Source: Meditron GitHub · HuggingFace transformers reproducibility discussions [11].
Extension Ideas
- Multi-task extension: Beyond clinical IE, run diagnosis prediction · summarization · translation · CoT reasoning benches in parallel.
- A/B prompt tuning: Automatic search for per-vendor optimal prompts (DSPy · APE · promptbreeder).
- Continuous benching: Weekly/monthly automatic bench runs → track model updates · detect regressions.
- Cost-accuracy Pareto optimization: Per-task Pareto frontier visualization → vendor selection guide.
- Offline vs online open models: Continuously add open lineage such as Meditron · Llama-Med · MedGemma · Med42.
- Hybrid routing: Route easy cases to cheap models (Haiku · GPT-4o-mini) and hard cases to top-tier models (Opus · o1).
Next Topics
- Topic 10
med-llm-reproduction: Reproduce HealthBench · MedQA benches (if this topic is a real-world task comparison, Topic 10 verifies standard bench reproducibility). - Topic 14
bio-mcp-agent: Expose bench results as an MCP tool → agent autonomously routes "which vendor for this task." - Topic 01
clinical-notes-ie-llm: Use this article's bench results as the basis for choosing a vendor in the Topic 01 pipeline.
References
- Anthropic Claude API pricing & benchmarks:
https://www.anthropic.com/pricing·https://www.anthropic.com/news/claude-3-family - OpenAI API pricing:
https://openai.com/api/pricing/ - Google AI Studio pricing (Gemini):
https://ai.google.dev/gemini-api/docs/pricing - Agrawal M, Hegselmann S, Lang H, et al. "Large Language Models are Few-Shot Clinical Information Extractors." NEJM AI 2024.
- Saab K, Tu T, Weng W-H, et al. "Capabilities of Gemini Models in Medicine (Med-Gemini)." Nature Medicine 2024.
https://www.nature.com/articles/s41591-024-03246-6 - Chen Z, Cano AH, Romanou A, et al. "Meditron-70B: Scaling Medical Pretraining for Large Language Models." EPFL 2023.
https://arxiv.org/abs/2311.16079 - OpenAI/Anthropic/Gemini developer forums structured output discussions
- OpenAI model versioning:
https://platform.openai.com/docs/models - Rate limit documentation:
https://docs.anthropic.com/en/api/rate-limits·https://platform.openai.com/docs/guides/rate-limits - DSPy (prompt optimization):
https://github.com/stanfordnlp/dspy· Khattab O et al. 2023 - Meditron GitHub:
https://github.com/epfLLM/meditron - HuggingFace transformers:
https://huggingface.co/docs/transformers/ - Anthropic Python SDK:
https://github.com/anthropics/anthropic-sdk-python - OpenAI Python SDK:
https://github.com/openai/openai-python - google-generativeai Python:
https://github.com/google/generative-ai-python - vLLM (high-performance open LLM serving):
https://github.com/vllm-project/vllm - Text Generation Inference (TGI, HuggingFace):
https://github.com/huggingface/text-generation-inference - Med42 (open medical LLM):
https://huggingface.co/m42-health/med42-70b - Anthropic Prompt Engineering Guide:
https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview - HELM (Holistic Evaluation of Language Models):
https://crfm.stanford.edu/helm/