Module 7: Technical comparison of providers
Project: Decision Tool
You've reached the end of the technical comparison module. You're going to build an automated Python tool that materializes everything you learned: it takes a requirements profile as input (volume, target latency, budget, constraints, the importance of each dimension) and emits a recommendation with quantitative justification.
It's the deliverable you take to your portfolio and to Module 8. Anyone on the team (or you yourself in 3 months) can run it, pass it a different profile, and get the answer without redoing the analysis from scratch.
By the end of this project you'll have:
- A Python CLI that ranks providers given a usage profile
- Declarative configuration (YAML/JSON) to register new providers without touching code
- A detailed report with score, per-dimension breakdown, and qualitative reasons
- Tests that validate the filtering, normalization and scoring logic
- Documentation on how to extend it for your specific product
Specification
Inputs
A product profile file, example product_profile.yaml:
name: "B2B SaaS support chatbot"
monthly_volume: 200000
avg_input_tokens: 600
avg_output_tokens: 250
warm_pool_hours_month: 200
constraints:
data_residency_eu: false
soc2_required: true
budget_max_usd: 1000
hipaa_required: false
latency_p95_max_s: 5.0
weights:
latency: 0.30
cost: 0.20
quality: 0.50
A provider catalog, providers.yaml:
providers:
- name: "OpenAI GPT-4o-mini"
type: "managed"
latency_p95_s: 2.3
price_input_per_1m: 0.15
price_output_per_1m: 0.60
quality_score: 86
meets_soc2: true
meets_hipaa_baa: true
meets_data_residency_eu: false
notes: "Safe default, medium lock-in"
- name: "Anthropic Claude 3.5 Sonnet"
type: "managed"
latency_p95_s: 2.8
price_input_per_1m: 3.00
price_output_per_1m: 15.00
quality_score: 92
meets_soc2: true
meets_hipaa_baa: true
meets_data_residency_eu: false
notes: "Superior reasoning; expensive"
- name: "OpenRouter Mistral 7B"
type: "managed"
latency_p95_s: 3.0
price_input_per_1m: 0.07
price_output_per_1m: 0.07
quality_score: 69
meets_soc2: false
meets_hipaa_baa: false
meets_data_residency_eu: false
notes: "Low cost; limited quality"
- name: "Modal Mistral 7B (A10G + warm)"
type: "serverless_gpu"
latency_p95_s: 2.7
price_gpu_per_second: 0.000306
gpu_seconds_per_request: 2.0
quality_score: 69
meets_soc2: true
meets_hipaa_baa: false
meets_data_residency_eu: true
notes: "Model control; cold starts to mitigate"
- name: "Self-hosted Ollama Mistral (EU)"
type: "selfhosted"
latency_p95_s: 5.5
price_vm_per_hour: 1.10
quality_score: 69
meets_soc2: true # depends on your compliance
meets_hipaa_baa: true
meets_data_residency_eu: true
notes: "Strict compliance; requires DevOps"
Output
A well-formatted CLI report with:
- Summary of the input profile
- List of providers discarded by constraints (with reason)
- Ranking with normalized scores
- Top 1 with the score breakdown
- Additional qualitative reasons
- Basic sensitivity analysis
Implementation
Create decision_tool.py:
# decision_tool.py
"""
Decision Tool for LLM provider selection.
Usage:
python decision_tool.py --profile product_profile.yaml --providers providers.yaml
"""
import argparse
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
import yaml
# ============================================================
# Data models
# ============================================================
@dataclass
class Provider:
name: str
type: str
latency_p95_s: float
quality_score: float
meets_soc2: bool = True
meets_hipaa_baa: bool = True
meets_data_residency_eu: bool = False
notes: str = ""
# Pricing fields - used depending on type
price_input_per_1m: float | None = None
price_output_per_1m: float | None = None
price_gpu_per_second: float | None = None
gpu_seconds_per_request: float | None = None
price_vm_per_hour: float | None = None
@classmethod
def from_dict(cls, d: dict) -> "Provider":
return cls(**{k: v for k, v in d.items() if k in cls.__annotations__})
def estimated_monthly_cost(
self,
requests_month: int,
input_tokens: int,
output_tokens: int,
warm_pool_hours: int,
) -> float:
if self.type == "managed":
return (
requests_month * input_tokens * self.price_input_per_1m / 1_000_000
+ requests_month * output_tokens * self.price_output_per_1m / 1_000_000
)
elif self.type == "serverless_gpu":
gpu_active = requests_month * self.gpu_seconds_per_request * self.price_gpu_per_second
warm = warm_pool_hours * 3600 * self.price_gpu_per_second
storage = 2.0
return gpu_active + warm + storage
elif self.type == "selfhosted":
return 720 * self.price_vm_per_hour
else:
raise ValueError(f"Unknown type: {self.type}")
@dataclass
class Profile:
name: str
monthly_volume: int
avg_input_tokens: int
avg_output_tokens: int
warm_pool_hours_month: int
constraints: dict
weights: dict
# ============================================================
# Loading
# ============================================================
def load_profile(path: Path) -> Profile:
with open(path) as f:
data = yaml.safe_load(f)
return Profile(
name=data["name"],
monthly_volume=data["monthly_volume"],
avg_input_tokens=data["avg_input_tokens"],
avg_output_tokens=data["avg_output_tokens"],
warm_pool_hours_month=data.get("warm_pool_hours_month", 0),
constraints=data.get("constraints", {}),
weights=data["weights"],
)
def load_providers(path: Path) -> list[Provider]:
with open(path) as f:
data = yaml.safe_load(f)
return [Provider.from_dict(p) for p in data["providers"]]
# ============================================================
# Filtering by constraints
# ============================================================
def filter_providers(providers: list[Provider], profile: Profile) -> tuple[list[Provider], list[tuple[str, list[str]]]]:
c = profile.constraints
valid = []
discarded = []
for p in providers:
reasons = []
if c.get("data_residency_eu") and not p.meets_data_residency_eu:
reasons.append("fails EU data residency")
if c.get("soc2_required") and not p.meets_soc2:
reasons.append("fails SOC2")
if c.get("hipaa_required") and not p.meets_hipaa_baa:
reasons.append("fails HIPAA BAA")
if c.get("latency_p95_max_s") and p.latency_p95_s > c["latency_p95_max_s"]:
reasons.append(f"P95 latency {p.latency_p95_s}s > max {c['latency_p95_max_s']}s")
cost = p.estimated_monthly_cost(
profile.monthly_volume,
profile.avg_input_tokens,
profile.avg_output_tokens,
profile.warm_pool_hours_month,
)
if c.get("budget_max_usd") and cost > c["budget_max_usd"]:
reasons.append(f"cost ${cost:.0f} > budget ${c['budget_max_usd']:.0f}")
if reasons:
discarded.append((p.name, reasons))
else:
valid.append(p)
return valid, discarded
# ============================================================
# Normalization and scoring
# ============================================================
def normalize(values: list[float], lower_is_better: bool) -> list[float]:
if not values or max(values) == min(values):
return [1.0] * len(values)
vmin, vmax = min(values), max(values)
if lower_is_better:
return [1 - (v - vmin) / (vmax - vmin) for v in values]
return [(v - vmin) / (vmax - vmin) for v in values]
def score_providers(providers: list[Provider], profile: Profile) -> list[dict]:
if not providers:
return []
latencies = [p.latency_p95_s for p in providers]
costs = [
p.estimated_monthly_cost(
profile.monthly_volume,
profile.avg_input_tokens,
profile.avg_output_tokens,
profile.warm_pool_hours_month,
)
for p in providers
]
qualities = [p.quality_score for p in providers]
n_lat = normalize(latencies, lower_is_better=True)
n_cost = normalize(costs, lower_is_better=True)
n_qual = normalize(qualities, lower_is_better=False)
weights = profile.weights
results = []
for p, lat, cost, qual, nl, nc, nq in zip(
providers, latencies, costs, qualities, n_lat, n_cost, n_qual
):
score = nl * weights["latency"] + nc * weights["cost"] + nq * weights["quality"]
results.append(
{
"provider": p,
"latency_s": lat,
"cost_monthly_usd": cost,
"quality_score": qual,
"n_latency": nl,
"n_cost": nc,
"n_quality": nq,
"contribution_latency": nl * weights["latency"],
"contribution_cost": nc * weights["cost"],
"contribution_quality": nq * weights["quality"],
"score_total": score,
}
)
return sorted(results, key=lambda x: x["score_total"], reverse=True)
# ============================================================
# Sensitivity analysis
# ============================================================
def sensitivity(providers: list[Provider], profile: Profile) -> dict:
"""Does the winner change if we move weights ±0.1?"""
base_weights = profile.weights.copy()
base = score_providers(providers, profile)
if not base:
return {"stable": False, "variations": []}
base_winner = base[0]["provider"].name
variations = []
for dim, delta in [("latency", 0.1), ("latency", -0.1), ("cost", 0.1), ("cost", -0.1), ("quality", 0.1), ("quality", -0.1)]:
new_weights = base_weights.copy()
new_weights[dim] = max(0, min(1, new_weights[dim] + delta))
# Renormalize so they sum to 1
total = sum(new_weights.values())
new_weights = {k: v / total for k, v in new_weights.items()}
temp_profile = Profile(
**{**profile.__dict__, "weights": new_weights}
)
r = score_providers(providers, temp_profile)
if r:
variations.append(
{"change": f"{dim} {'+' if delta > 0 else ''}{delta}", "winner": r[0]["provider"].name}
)
distinct = {v["winner"] for v in variations}
return {"stable": len(distinct) == 1 and base_winner in distinct, "variations": variations}
# ============================================================
# Report
# ============================================================
def report(profile: Profile, discarded, ranking, sens):
sep = "=" * 80
print(f"\n{sep}")
print(f" DECISION TOOL — {profile.name}")
print(f"{sep}\n")
print(f"📋 Usage profile:")
print(f" Volume: {profile.monthly_volume:,} req/month")
print(f" Tokens: {profile.avg_input_tokens} input + {profile.avg_output_tokens} output")
print(f" Warm pool: {profile.warm_pool_hours_month} hrs/month")
print(f"\n⚖️ Weights: lat={profile.weights['latency']}, cost={profile.weights['cost']}, qual={profile.weights['quality']}")
if discarded:
print(f"\n❌ Discarded ({len(discarded)}):")
for name, reasons in discarded:
print(f" {name}: {'; '.join(reasons)}")
if not ranking:
print("\n⚠️ No provider meets the constraints. Review the profile.")
return
print(f"\n📊 Ranking ({len(ranking)} candidates):")
print(f" {'Provider':<40} {'P95':>6} {'$/mo':>10} {'Qual':>5} {'Score':>7}")
print(f" {'-' * 70}")
for r in ranking:
p = r["provider"]
print(f" {p.name:<40} {r['latency_s']:>5.2f}s {r['cost_monthly_usd']:>9.0f} {r['quality_score']:>4.0f} {r['score_total']:>7.3f}")
top = ranking[0]
p = top["provider"]
print(f"\n🏆 Recommendation: {p.name}")
print(f" Score total: {top['score_total']:.3f}")
print(f" Breakdown:")
print(f" Latency: {top['contribution_latency']:.3f} (norm {top['n_latency']:.2f})")
print(f" Cost: {top['contribution_cost']:.3f} (norm {top['n_cost']:.2f})")
print(f" Quality: {top['contribution_quality']:.3f} (norm {top['n_quality']:.2f})")
if p.notes:
print(f" Notes: {p.notes}")
print(f"\n🔬 Sensitivity analysis (±0.1 movement in weights):")
if sens["stable"]:
print(f" ✅ Robust decision — the winner doesn't change with weight variations")
else:
print(f" ⚠️ Decision sensitive to weights — the winner changes with weighting:")
for v in sens["variations"]:
print(f" {v['change']:<15} → {v['winner']}")
# ============================================================
# CLI
# ============================================================
def main():
parser = argparse.ArgumentParser(description="Decision tool for LLM provider selection")
parser.add_argument("--profile", required=True, type=Path, help="YAML with the product profile")
parser.add_argument("--providers", required=True, type=Path, help="YAML with the provider catalog")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
profile = load_profile(args.profile)
providers = load_providers(args.providers)
valid, discarded = filter_providers(providers, profile)
ranking = score_providers(valid, profile)
sens = sensitivity(valid, profile)
if args.json:
print(json.dumps(
{
"profile": profile.name,
"ranking": [
{
"provider": r["provider"].name,
"score": r["score_total"],
"cost_monthly": r["cost_monthly_usd"],
"latency_p95": r["latency_s"],
"quality": r["quality_score"],
}
for r in ranking
],
"discarded": [{"name": n, "reasons": r} for n, r in discarded],
"sensitivity_stable": sens["stable"],
},
indent=2,
))
else:
report(profile, discarded, ranking, sens)
if __name__ == "__main__":
main()
Tests
Create test_decision_tool.py:
# test_decision_tool.py
import pytest
from decision_tool import Provider, Profile, filter_providers, score_providers, normalize, sensitivity
def basic_profile():
return Profile(
name="test",
monthly_volume=100_000,
avg_input_tokens=500,
avg_output_tokens=200,
warm_pool_hours_month=0,
constraints={"budget_max_usd": 500},
weights={"latency": 0.3, "cost": 0.4, "quality": 0.3},
)
def test_normalize_lower_is_better():
n = normalize([1.0, 2.0, 4.0], lower_is_better=True)
assert n[0] == 1.0 # lower = better = 1
assert n[2] == 0.0 # higher = worse = 0
assert 0 < n[1] < 1 # intermediate
def test_normalize_equal_returns_ones():
n = normalize([5.0, 5.0], lower_is_better=True)
assert n == [1.0, 1.0]
def test_filter_by_budget():
p_expensive = Provider(
name="Expensive", type="managed", latency_p95_s=2.0, quality_score=90,
price_input_per_1m=10, price_output_per_1m=30,
)
p_cheap = Provider(
name="Cheap", type="managed", latency_p95_s=3.0, quality_score=70,
price_input_per_1m=0.1, price_output_per_1m=0.5,
)
valid, discarded = filter_providers([p_expensive, p_cheap], basic_profile())
assert len(valid) == 1
assert valid[0].name == "Cheap"
assert any("budget" in r for _, reasons in discarded for r in reasons)
def test_filter_by_data_residency():
profile = basic_profile()
profile.constraints = {"data_residency_eu": True}
p_us = Provider(name="US", type="managed", latency_p95_s=2, quality_score=80,
price_input_per_1m=0.1, price_output_per_1m=0.1,
meets_data_residency_eu=False)
p_eu = Provider(name="EU", type="managed", latency_p95_s=2, quality_score=80,
price_input_per_1m=0.1, price_output_per_1m=0.1,
meets_data_residency_eu=True)
valid, _ = filter_providers([p_us, p_eu], profile)
assert {v.name for v in valid} == {"EU"}
def test_scoring_rewards_best_at_everything():
"""A provider strictly better at everything should win."""
p1 = Provider(name="Best", type="managed", latency_p95_s=1.0, quality_score=95,
price_input_per_1m=0.05, price_output_per_1m=0.05)
p2 = Provider(name="Worst", type="managed", latency_p95_s=5.0, quality_score=60,
price_input_per_1m=5.0, price_output_per_1m=15.0)
ranking = score_providers([p1, p2], basic_profile())
assert ranking[0]["provider"].name == "Best"
def test_sensitivity_detects_instability():
# Two providers close in score → HIGH sensitivity
p1 = Provider(name="A", type="managed", latency_p95_s=2.0, quality_score=85,
price_input_per_1m=0.3, price_output_per_1m=0.6)
p2 = Provider(name="B", type="managed", latency_p95_s=2.1, quality_score=84,
price_input_per_1m=0.3, price_output_per_1m=0.6)
# Near-twins — some weight changes should change the winner or not
sens = sensitivity([p1, p2], basic_profile())
# No strict assert — we just verify the function returns a valid structure
assert "stable" in sens
assert "variations" in sens
Run:
pip install pytest pyyaml
pytest test_decision_tool.py -v
Real usage
# Case 1: B2B SaaS standard
python decision_tool.py --profile product_profile.yaml --providers providers.yaml
# Case 2: client with HIPAA
cat > hipaa_profile.yaml <<EOF
name: "Medical assistant"
monthly_volume: 50000
avg_input_tokens: 800
avg_output_tokens: 300
warm_pool_hours_month: 100
constraints:
hipaa_required: true
soc2_required: true
budget_max_usd: 2000
weights:
latency: 0.2
cost: 0.2
quality: 0.6
EOF
python decision_tool.py --profile hipaa_profile.yaml --providers providers.yaml
# Case 3: JSON output to integrate into a pipeline
python decision_tool.py --profile product_profile.yaml --providers providers.yaml --json > recommendation.json
Suggested README
# LLM Provider Decision Tool
Recommends the optimal LLM provider given your usage profile.
## Installation
```bash
pip install pyyaml
```
## Usage
```bash
python decision_tool.py --profile your_profile.yaml --providers providers.yaml
```
## File structure
- `product_profile.yaml`: your product's requirements and priorities
- `providers.yaml`: provider catalog with their metrics
- `decision_tool.py`: the script
## Extend
To add a provider: edit `providers.yaml`. Supported types:
- `managed`: pricing per tokens (OpenAI, Anthropic, OpenRouter)
- `serverless_gpu`: pricing per GPU second (Modal, Replicate)
- `selfhosted`: pricing per VM hour (AWS, GCP)
To change weights: edit `product_profile.yaml`. The weights must sum to 1.0.
## Tests
```bash
pytest test_decision_tool.py -v
```
## Important
The provider numbers in `providers.yaml` are **references**. Replace them with your real benchmarks (capsules 02-04 of the path) before using in production.
Self-assessment
Before closing the module:
- The script runs without errors with the example files
- Changing weights in
product_profile.yamlchanges the ranking - Enabling constraints (
hipaa_required,data_residency_eu) discards providers correctly - The tests pass (
pytest -v) - The JSON output with
--jsonis valid and parseable - The sensitivity analysis detects when there's a close tie
Connection with the rest of the path
What you built is the qualitative input of Module 8 (Unified Client):
- Your decision tool says "OpenAI wins in this profile"
- The M08 Unified Client lets you switch providers with one parameter
- When your profile changes (prices, constraints), you run decision tool → get a new winner → switch the provider in Unified Client → near-zero migration
Your work here doesn't end with M07. It starts taking real value in M08.
Module 7 completed
You did something uncommon: you turned LLM provider selection into a reproducible, defensible process. You'll be able to show this decision tool in interviews or use your own output in real product decisions.
Concretely, you can now:
- ✅ Benchmark latency with real P50/P95/P99
- ✅ Calculate honest costs with hidden costs included
- ✅ Measure quality with a rubric + LLM-as-judge
- ✅ Combine dimensions in a weighted matrix with sensitivity
- ✅ Migrate between providers knowing the cost of each migration
- ✅ Rank options with an automated CLI tool
Next module
Module 8 — Unified AI Client is the close of the path. You're going to build the client that encapsulates all the providers you got to know behind a single interface, with automatic fallback, declarative configuration, and multi-provider testing. It's the deliverable you take to your portfolio as proof of mastery of the topic.
Resources
- TOPSIS algorithm (Python implementation) — alternative to the linear scoring method.
- Click — Python CLI framework — to evolve your CLI if it grows.
- PyYAML docs — to understand more loading options.
- Hypothesis — property-based testing — deeper testing of the scoring logic.
- Decision making with multi-objective optimization — applicable theory if you scale the tool.