pubHealthLLM / tests /test_model_selection.py
mike-malloy's picture
Add multi-provider model selection, decision tree simulation engine, and formatter
0d4b1ee
Raw
History Blame Contribute Delete
17.9 kB
"""
Tests for the model selection dropdown feature.
Covers:
- Model map structure and constants (no API calls)
- Agent creation and caching per model (Anthropic models need API key)
- Groq provider: missing API key gives a clean EnvironmentError (no API calls)
- System prompt selection: Groq gets the short prompt, Anthropic gets the full one
- Groq prompt token budget: short enough to stay under Groq's 12,000 TPM cap
- run_agent() function signature accepts the model parameter
- Gradio chat() function signature accepts the model parameter
- Dropdown constants: correct options, default, and note text
- Examples are in list-of-lists format (Gradio requirement with additional_inputs)
No live API calls are made except in tests that explicitly use the
anthropic_api_key fixture (those are skipped if the key is absent).
"""
import inspect
import os
import pytest
# ---------------------------------------------------------------------------
# 1. Model map structure
# ---------------------------------------------------------------------------
def test_model_map_has_expected_entries():
"""_MODEL_MAP must contain exactly the documented model keys."""
from pubhealth_llm.app.agent import _MODEL_MAP
expected_keys = {
"anthropic:claude-sonnet-4-6",
"anthropic:claude-haiku-4",
"openai:gpt-4o-mini",
"groq:llama-3.3-70b-versatile",
"groq:llama-3.1-8b-instant",
}
assert set(_MODEL_MAP.keys()) == expected_keys, (
f"Model map keys mismatch.\n Expected: {expected_keys}\n Got: {set(_MODEL_MAP.keys())}"
)
def test_model_map_providers_are_valid():
"""Every entry in _MODEL_MAP must declare a known provider."""
from pubhealth_llm.app.agent import _MODEL_MAP
valid_providers = {"anthropic", "openai", "groq"}
for key, (provider, _) in _MODEL_MAP.items():
assert provider in valid_providers, (
f"Model key {key!r} has unknown provider {provider!r}"
)
def test_model_map_anthropic_keys_have_model_ids():
"""Anthropic entries must map to non-empty API model ID strings."""
from pubhealth_llm.app.agent import _MODEL_MAP
for key, (provider, model_id) in _MODEL_MAP.items():
if provider == "anthropic":
assert model_id, f"Empty model_id for key {key!r}"
assert "claude" in model_id.lower(), (
f"Anthropic model ID {model_id!r} doesn't look like a Claude model"
)
def test_model_map_groq_keys_have_model_ids():
"""Groq entries must map to non-empty API model ID strings."""
from pubhealth_llm.app.agent import _MODEL_MAP
for key, (provider, model_id) in _MODEL_MAP.items():
if provider == "groq":
assert model_id, f"Empty model_id for key {key!r}"
assert "llama" in model_id.lower(), (
f"Groq model ID {model_id!r} doesn't look like a Llama model"
)
def test_default_model_key_is_claude_sonnet():
"""DEFAULT_MODEL_KEY must be the Claude Sonnet entry."""
from pubhealth_llm.app.agent import DEFAULT_MODEL_KEY
assert DEFAULT_MODEL_KEY == "anthropic:claude-sonnet-4-6", (
f"Expected 'anthropic:claude-sonnet-4-6', got {DEFAULT_MODEL_KEY!r}"
)
def test_default_model_key_exists_in_model_map():
"""DEFAULT_MODEL_KEY must be a valid key in _MODEL_MAP."""
from pubhealth_llm.app.agent import DEFAULT_MODEL_KEY, _MODEL_MAP
assert DEFAULT_MODEL_KEY in _MODEL_MAP, (
f"DEFAULT_MODEL_KEY {DEFAULT_MODEL_KEY!r} not found in _MODEL_MAP"
)
# ---------------------------------------------------------------------------
# 2. Agent creation — Anthropic models (requires API key)
# ---------------------------------------------------------------------------
def test_create_agent_default_model(anthropic_api_key):
"""_create_agent() with no argument uses the default Claude Sonnet model."""
from pubhealth_llm.app.agent import _create_agent, DEFAULT_MODEL_KEY
agent = _create_agent()
assert agent is not None, "_create_agent() returned None"
def test_create_agent_claude_sonnet(anthropic_api_key):
"""_create_agent() succeeds for anthropic:claude-sonnet-4-6."""
from pubhealth_llm.app.agent import _create_agent
agent = _create_agent("anthropic:claude-sonnet-4-6")
assert agent is not None
def test_create_agent_claude_haiku(anthropic_api_key):
"""_create_agent() succeeds for anthropic:claude-haiku-4."""
from pubhealth_llm.app.agent import _create_agent
agent = _create_agent("anthropic:claude-haiku-4")
assert agent is not None
def test_create_agent_anthropic_has_eight_tools(anthropic_api_key):
"""Each Anthropic agent must expose all eight tools."""
from pubhealth_llm.app.agent import _create_agent
expected = {
"tool_search_mmwr_reports",
"tool_get_health_statistics",
"tool_compare_locations",
"tool_get_available_measures",
"tool_get_worst_counties_by_measure",
"tool_rank_counties_composite",
"tool_get_mortality_data",
"tool_compare_mortality",
}
for model_key in ("anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4"):
agent = _create_agent(model_key)
tool_names = set(agent._function_toolset.tools.keys())
assert tool_names == expected, (
f"Tool mismatch for {model_key!r}.\n"
f" Expected: {expected}\n Got: {tool_names}"
)
# ---------------------------------------------------------------------------
# 3. Agent caching
# ---------------------------------------------------------------------------
def test_get_agent_returns_same_instance(anthropic_api_key):
"""get_agent() must return the identical object on repeated calls."""
from pubhealth_llm.app.agent import get_agent, _agent_cache
# Clear cache so we can test a fresh creation
_agent_cache.clear()
agent1 = get_agent("anthropic:claude-sonnet-4-6")
agent2 = get_agent("anthropic:claude-sonnet-4-6")
assert agent1 is agent2, (
"get_agent() returned different objects for the same model key — "
"caching is broken"
)
def test_get_agent_different_keys_return_different_instances(anthropic_api_key):
"""get_agent() must return distinct objects for different model keys."""
from pubhealth_llm.app.agent import get_agent, _agent_cache
_agent_cache.clear()
sonnet = get_agent("anthropic:claude-sonnet-4-6")
haiku = get_agent("anthropic:claude-haiku-4")
assert sonnet is not haiku, (
"get_agent() returned the same object for different model keys"
)
# ---------------------------------------------------------------------------
# 4. Invalid model key
# ---------------------------------------------------------------------------
def test_create_agent_invalid_key_raises(anthropic_api_key):
"""_create_agent() must raise ValueError for an unknown model key."""
from pubhealth_llm.app.agent import _create_agent
with pytest.raises(ValueError, match="Unknown model key"):
_create_agent("openai:gpt-4o")
# ---------------------------------------------------------------------------
# 5. Groq: missing API key gives a clean EnvironmentError
# ---------------------------------------------------------------------------
def test_groq_agent_missing_key_raises_environment_error():
"""
Attempting to create a Groq agent without GROQ_API_KEY set must raise
EnvironmentError with an informative message — not crash with a traceback
from deep inside the Groq library.
"""
from pubhealth_llm.app.agent import _create_agent, _agent_cache
original_key = os.environ.pop("GROQ_API_KEY", None)
_agent_cache.pop("groq:llama-3.3-70b-versatile", None)
try:
with pytest.raises(EnvironmentError, match="GROQ_API_KEY"):
_create_agent("groq:llama-3.3-70b-versatile")
finally:
if original_key is not None:
os.environ["GROQ_API_KEY"] = original_key
_agent_cache.pop("groq:llama-3.3-70b-versatile", None)
def test_groq_8b_missing_key_raises_environment_error():
"""Same EnvironmentError check for the Llama 3.1 8B Instant model."""
from pubhealth_llm.app.agent import _create_agent, _agent_cache
original_key = os.environ.pop("GROQ_API_KEY", None)
_agent_cache.pop("groq:llama-3.1-8b-instant", None)
try:
with pytest.raises(EnvironmentError, match="GROQ_API_KEY"):
_create_agent("groq:llama-3.1-8b-instant")
finally:
if original_key is not None:
os.environ["GROQ_API_KEY"] = original_key
_agent_cache.pop("groq:llama-3.1-8b-instant", None)
# ---------------------------------------------------------------------------
# 6. System prompt selection
# ---------------------------------------------------------------------------
def test_groq_gets_short_system_prompt(anthropic_api_key):
"""
SYSTEM_PROMPT_GROQ must be strictly shorter than SYSTEM_PROMPT.
Groq's 12,000 TPM limit means the full prompt risks exceeding
per-request token budgets.
"""
from pubhealth_llm.app.agent import SYSTEM_PROMPT, SYSTEM_PROMPT_GROQ
assert len(SYSTEM_PROMPT_GROQ) < len(SYSTEM_PROMPT), (
"SYSTEM_PROMPT_GROQ must be shorter than SYSTEM_PROMPT. "
f"Groq: {len(SYSTEM_PROMPT_GROQ)} chars, Full: {len(SYSTEM_PROMPT)} chars"
)
def test_groq_prompt_under_token_budget():
"""
SYSTEM_PROMPT_GROQ must fit well within Groq's 12,000 TPM cap.
Rough estimate: 1 token ≈ 4 characters. The prompt itself plus 8 tool
definitions plus a typical user question should stay under 10,000 tokens
to leave headroom. We check the prompt alone is under 2,000 tokens
(~8,000 characters) — a conservative ceiling.
"""
from pubhealth_llm.app.agent import SYSTEM_PROMPT_GROQ
approx_tokens = len(SYSTEM_PROMPT_GROQ) / 4
assert approx_tokens < 2_000, (
f"SYSTEM_PROMPT_GROQ is ~{approx_tokens:.0f} tokens — too large for "
f"Groq's 12,000 TPM cap once tool definitions and the user message "
f"are included. Current length: {len(SYSTEM_PROMPT_GROQ)} chars."
)
def test_groq_prompt_contains_tool_routing_rules():
"""SYSTEM_PROMPT_GROQ must still contain the essential tool-routing rules."""
from pubhealth_llm.app.agent import SYSTEM_PROMPT_GROQ
required_phrases = [
"tool_get_health_statistics",
"tool_search_mmwr_reports",
"tool_rank_counties_composite",
"tool_compare_mortality",
"decision support",
]
for phrase in required_phrases:
assert phrase in SYSTEM_PROMPT_GROQ, (
f"SYSTEM_PROMPT_GROQ is missing required phrase: {phrase!r}"
)
def test_full_prompt_contains_writing_quality_section():
"""SYSTEM_PROMPT must contain the writing quality instructions absent from Groq prompt."""
from pubhealth_llm.app.agent import SYSTEM_PROMPT, SYSTEM_PROMPT_GROQ
assert "WRITING QUALITY" in SYSTEM_PROMPT, (
"Full SYSTEM_PROMPT is missing the WRITING QUALITY section"
)
assert "WRITING QUALITY" not in SYSTEM_PROMPT_GROQ, (
"SYSTEM_PROMPT_GROQ should not contain the WRITING QUALITY section "
"(it adds ~400 tokens Groq can't afford)"
)
# ---------------------------------------------------------------------------
# 7. run_agent() function signature
# ---------------------------------------------------------------------------
def test_run_agent_accepts_model_parameter():
"""run_agent() must accept a 'model' keyword argument."""
from pubhealth_llm.app.agent import run_agent
sig = inspect.signature(run_agent)
params = sig.parameters
assert "model" in params, (
f"run_agent() missing 'model' parameter. Got: {list(params.keys())}"
)
def test_run_agent_model_parameter_defaults_to_none():
"""run_agent()'s 'model' parameter must default to None."""
from pubhealth_llm.app.agent import run_agent
sig = inspect.signature(run_agent)
default = sig.parameters["model"].default
assert default is None, (
f"run_agent() 'model' default should be None, got {default!r}"
)
# ---------------------------------------------------------------------------
# 8. Gradio chat() function signature
# ---------------------------------------------------------------------------
def test_chat_accepts_model_parameter():
"""chat() must accept a 'model' keyword argument."""
from pubhealth_llm.app.gradio_app import chat
sig = inspect.signature(chat)
params = sig.parameters
assert "model" in params, (
f"chat() missing 'model' parameter. Got: {list(params.keys())}"
)
def test_chat_model_parameter_has_default():
"""chat()'s 'model' parameter must have a default (so history-only calls still work)."""
from pubhealth_llm.app.gradio_app import chat, DEFAULT_MODEL
sig = inspect.signature(chat)
default = sig.parameters["model"].default
assert default == DEFAULT_MODEL, (
f"chat() 'model' default should be {DEFAULT_MODEL!r}, got {default!r}"
)
def test_chat_is_async():
"""chat() must remain an async function for Gradio async support."""
import asyncio
from pubhealth_llm.app.gradio_app import chat
assert asyncio.iscoroutinefunction(chat), (
"chat() must be an async function"
)
# ---------------------------------------------------------------------------
# 9. Gradio dropdown constants
# ---------------------------------------------------------------------------
def test_model_options_has_five_entries():
"""MODEL_OPTIONS must have exactly five entries matching _MODEL_MAP."""
from pubhealth_llm.app.gradio_app import MODEL_OPTIONS
assert len(MODEL_OPTIONS) == 5, (
f"Expected 5 model options, got {len(MODEL_OPTIONS)}: {MODEL_OPTIONS}"
)
def test_model_options_are_label_value_tuples():
"""Each MODEL_OPTIONS entry must be a (label, value) tuple."""
from pubhealth_llm.app.gradio_app import MODEL_OPTIONS
for entry in MODEL_OPTIONS:
assert isinstance(entry, (tuple, list)) and len(entry) == 2, (
f"MODEL_OPTIONS entry {entry!r} must be a (label, value) pair"
)
label, value = entry
assert isinstance(label, str) and label, f"Empty label in entry {entry!r}"
assert isinstance(value, str) and value, f"Empty value in entry {entry!r}"
def test_model_options_values_match_model_map():
"""Every MODEL_OPTIONS value must exist as a key in _MODEL_MAP."""
from pubhealth_llm.app.gradio_app import MODEL_OPTIONS
from pubhealth_llm.app.agent import _MODEL_MAP
for label, value in MODEL_OPTIONS:
assert value in _MODEL_MAP, (
f"Dropdown value {value!r} (label: {label!r}) not found in _MODEL_MAP. "
f"Valid keys: {list(_MODEL_MAP.keys())}"
)
def test_default_model_is_in_options():
"""DEFAULT_MODEL must appear as one of the dropdown option values."""
from pubhealth_llm.app.gradio_app import MODEL_OPTIONS, DEFAULT_MODEL
values = [v for _, v in MODEL_OPTIONS]
assert DEFAULT_MODEL in values, (
f"DEFAULT_MODEL {DEFAULT_MODEL!r} not found in MODEL_OPTIONS values: {values}"
)
def test_default_model_is_first_option():
"""The default model must be the first dropdown entry (top of list = default selection)."""
from pubhealth_llm.app.gradio_app import MODEL_OPTIONS, DEFAULT_MODEL
first_value = MODEL_OPTIONS[0][1]
assert first_value == DEFAULT_MODEL, (
f"First MODEL_OPTIONS entry should be DEFAULT_MODEL {DEFAULT_MODEL!r}, "
f"got {first_value!r}"
)
def test_model_note_is_non_empty_string():
"""MODEL_NOTE must be a non-empty string."""
from pubhealth_llm.app.gradio_app import MODEL_NOTE
assert isinstance(MODEL_NOTE, str) and MODEL_NOTE.strip(), (
"MODEL_NOTE must be a non-empty string"
)
def test_model_note_mentions_claude_and_llama():
"""MODEL_NOTE text must mention both Claude and Llama so the hint is meaningful."""
from pubhealth_llm.app.gradio_app import MODEL_NOTE
assert "Claude" in MODEL_NOTE or "claude" in MODEL_NOTE, (
f"MODEL_NOTE should mention Claude: {MODEL_NOTE!r}"
)
assert "Llama" in MODEL_NOTE or "llama" in MODEL_NOTE, (
f"MODEL_NOTE should mention Llama: {MODEL_NOTE!r}"
)
# ---------------------------------------------------------------------------
# 10. Examples format (Gradio requirement with additional_inputs)
# ---------------------------------------------------------------------------
def test_examples_are_list_of_lists():
"""
When additional_inputs are present, Gradio requires examples as
[[message, input1, ...]] — not a flat list of strings.
"""
from pubhealth_llm.app.gradio_app import EXAMPLE_QUESTIONS, DEFAULT_MODEL
# Simulate what build_app() does
examples_with_model = [[q, DEFAULT_MODEL] for q in EXAMPLE_QUESTIONS]
for entry in examples_with_model:
assert isinstance(entry, list), (
f"Example entry {entry!r} must be a list, not {type(entry).__name__}"
)
assert len(entry) == 2, (
f"Example entry {entry!r} must have 2 elements [question, model]"
)
question, model = entry
assert isinstance(question, str) and question, "Question must be a non-empty string"
assert model == DEFAULT_MODEL, (
f"Example model value should be DEFAULT_MODEL {DEFAULT_MODEL!r}, got {model!r}"
)
def test_build_app_succeeds_with_dropdown():
"""
build_app() must complete without raising even with the dropdown
and additional_inputs wired in.
"""
import gradio as gr
from pubhealth_llm.app.gradio_app import build_app
app = build_app()
assert isinstance(app, gr.Blocks)