CoolFace
Apppublic

BioinstLab/gmass-demo

sourceHugging Faceapache-2.0updated 2d agoView on Hugging Face
0likes
router.py702 linesDownload Raw Back to models
1# models/router.py2# MediSafe-GH · G-MASS Project3# Biomedical Technologies Lab4#5# Unified model router for the probe-tested evaluation models.6# - Phi-3 Mini    -> HuggingFace Inference Router (router.huggingface.co/v1)7# - BioMistral    -> HuggingFace Inference Router (router.huggingface.co/v1)8# - GPT-4o        -> OpenAI API9# - Gemini        -> Google GenAI API (new SDK)10#11# Usage:12#   from models.router import call_model13#   response = call_model("llama", "Your prompt here")14 15import os16import re17import time18from dotenv import load_dotenv19 20load_dotenv()21 22# -- API credentials ------------------------------------------------------------23HF_TOKEN   = os.getenv("HF_TOKEN")24OPENAI_KEY = os.getenv("OPENAI_API_KEY")25GEMINI_KEY = os.getenv("GEMINI_API_KEY")26DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"27DEFAULT_GEMINI_FALLBACK_MODELS = "gemini-2.5-flash-lite"28GEMINI_MODEL = os.getenv("GEMINI_MODEL", DEFAULT_GEMINI_MODEL)29GEMINI_FALLBACK_MODELS = [30    model.strip()31    for model in os.getenv(32        "GEMINI_FALLBACK_MODELS",33        DEFAULT_GEMINI_FALLBACK_MODELS,34    ).split(",")35    if model.strip()36]37GEMINI_RETRIES = int(os.getenv("GEMINI_RETRIES", "4"))38GEMINI_RETRY_DELAY = float(os.getenv("GEMINI_RETRY_DELAY", "2"))39HF_RETRIES = int(os.getenv("HF_RETRIES", "4"))40HF_RETRY_DELAY = float(os.getenv("HF_RETRY_DELAY", "2"))41PHI3_MODEL = os.getenv("PHI3_MODEL", "microsoft/Phi-3-mini-4k-instruct")42BIOMISTRAL_MODEL = os.getenv("BIOMISTRAL_MODEL", "BioMistral/BioMistral-7B-SLERP")43LOCAL_MODEL_BACKEND = os.getenv("LOCAL_MODEL_BACKEND", "hf_router").lower()44PHI3_BACKEND = os.getenv("PHI3_BACKEND", LOCAL_MODEL_BACKEND).lower()45BIOMISTRAL_BACKEND = os.getenv("BIOMISTRAL_BACKEND", LOCAL_MODEL_BACKEND).lower()46LOCAL_MAX_NEW_TOKENS = int(os.getenv("LOCAL_MAX_NEW_TOKENS", "512"))47LOCAL_TEMPERATURE = float(os.getenv("LOCAL_TEMPERATURE", "0"))48LOCAL_DEVICE_MAP = os.getenv("LOCAL_DEVICE_MAP", "auto")49LOCAL_TORCH_DTYPE = os.getenv("LOCAL_TORCH_DTYPE", "auto")50LOCAL_QUANTIZATION = os.getenv("LOCAL_QUANTIZATION", "none").lower()51LOCAL_QUANTIZATION_FALLBACK = os.getenv(52    "LOCAL_QUANTIZATION_FALLBACK",53    "true",54).lower() in ("1", "true", "yes")55LOCAL_ATTN_IMPLEMENTATION = os.getenv("LOCAL_ATTN_IMPLEMENTATION", "eager")56LOCAL_TRUST_REMOTE_CODE = os.getenv("LOCAL_TRUST_REMOTE_CODE", "false").lower() in (57    "1",58    "true",59    "yes",60)61PHI3_LOCAL_BASE_URL = os.getenv("PHI3_LOCAL_BASE_URL", "http://localhost:8000/v1")62BIOMISTRAL_LOCAL_BASE_URL = os.getenv("BIOMISTRAL_LOCAL_BASE_URL", "http://localhost:8001/v1")63PHI3_LOCAL_MODEL = os.getenv("PHI3_LOCAL_MODEL", PHI3_MODEL)64BIOMISTRAL_LOCAL_MODEL = os.getenv("BIOMISTRAL_LOCAL_MODEL", BIOMISTRAL_MODEL)65 66_TRANSFORMERS_CACHE = {}67 68 69# -- Language-consistency instruction (clarifications §8) ----------------------70# Frontier models often default to English even when prompted in Twi.71# Appending this instruction forces language-consistent responses where the72# model is capable of complying, and surfaces non-compliance as a documented73# finding where it is not (checked downstream by LanguageConsistencyChecker74# in scorer/scorer.py).75_DEFAULT_LANGUAGE_CONSISTENCY_INSTRUCTION = (76    "\n\nPlease respond in the same language as the question."77)78 79_LANGUAGE_SPECIFIC_INSTRUCTIONS = {80    "twi": (81        "\n\nRespond only in Twi/Akan. Do not answer in English, and do not "82        "translate the question into English."83    ),84    "ghanaian_en": (85        "\n\nRespond in Ghanaian English. Keep the answer in English, using "86        "natural Ghanaian wording where appropriate."87    ),88}89 90# Languages that require the consistency instruction. English needs no91# instruction since it's almost always the model's default behaviour anyway.92_LANGUAGES_REQUIRING_INSTRUCTION = {"twi", "ghanaian_en"}93 94 95def build_prompt_with_language_instruction(prompt: str, language: str = "english") -> str:96    """97    Append the §8 language-consistency instruction for non-English probes.98    No-op for English. Call this BEFORE passing a prompt to call_model()99    when evaluating Twi or Ghanaian English conditions.100 101    Args:102        prompt   : the original probe text103        language : "english", "twi", or "ghanaian_en"104 105    Returns:106        The prompt, with the consistency instruction appended if needed.107 108    Example:109        prompt = build_prompt_with_language_instruction(twi_prompt, "twi")110        response = call_model("gemini", prompt)111    """112    if language in _LANGUAGES_REQUIRING_INSTRUCTION:113        return prompt + _LANGUAGE_SPECIFIC_INSTRUCTIONS.get(114            language,115            _DEFAULT_LANGUAGE_CONSISTENCY_INSTRUCTION,116        )117    return prompt118 119 120def normalize_model_name(model_name: str) -> str:121    """Return the canonical model key used by the router."""122    return str(model_name).strip().lower()123 124 125def clean_model_response(text: str) -> str:126    """Remove common chat-template artifacts from model outputs."""127    cleaned = str(text or "").strip()128    if not cleaned:129        return cleaned130 131    cleaned = re.sub(r"<\|/?(?:assistant|user|system)\|>", "", cleaned, flags=re.IGNORECASE).strip()132    cleaned = re.sub(r"<\|(?:end|eot|endoftext)\|>", "", cleaned, flags=re.IGNORECASE).strip()133    cleaned = re.sub(r"^(?:assistant|ai|model)\s*:\s*", "", cleaned, flags=re.IGNORECASE)134    cleaned = re.split(r"\n\s*(?:User|Patient)\s*:", cleaned, maxsplit=1)[0]135    return cleaned.strip()136 137 138# ------------------------------------------------------------------------------139# HUGGINGFACE INFERENCE ROUTER  (LLaMA · Phi-3 · BioMistral)140# Endpoint: https://router.huggingface.co/v1  (OpenAI-compatible)141# No local downloads -- models run on HuggingFace servers142# ------------------------------------------------------------------------------143 144def call_hf_model(model_id: str, prompt: str) -> str:145    """146    Calls HuggingFace's Inference Router using the OpenAI-compatible API.147    No local download needed -- model runs on HuggingFace servers.148 149    Args:150        model_id : full HuggingFace model ID e.g. "meta-llama/Llama-3.2-3B-Instruct"151        prompt   : the text prompt to send152 153    Returns:154        The model's generated text as a string.155    """156    if not HF_TOKEN:157        raise EnvironmentError(158            "HF_TOKEN is missing. Add it to your .env file.\n"159            "Get one at: huggingface.co -> Settings -> Access Tokens"160        )161 162    from openai import OpenAI163 164    client = OpenAI(165        base_url="https://router.huggingface.co/v1",166        api_key=HF_TOKEN,167    )168 169    last_error = None170    for attempt in range(1, HF_RETRIES + 1):171        try:172            response = client.chat.completions.create(173                model=model_id,174                messages=[{"role": "user", "content": prompt}],175                max_tokens=512,176            )177            text = clean_model_response(response.choices[0].message.content)178            if not text:179                raise RuntimeError(f"{model_id} returned an empty response.")180            return text181        except Exception as e:182            last_error = e183            if not _is_retryable_hf_error(e) or attempt == HF_RETRIES:184                break185 186            delay = HF_RETRY_DELAY * (2 ** (attempt - 1))187            print(188                f"  HuggingFace transient error on {model_id}; "189                f"retrying in {delay:.1f}s ({attempt}/{HF_RETRIES})..."190            )191            time.sleep(delay)192 193    raise last_error194 195 196def _is_retryable_hf_error(error: Exception) -> bool:197    """Return True for temporary Hugging Face router/provider failures."""198    message = str(error).lower()199    non_retryable_markers = (200        "model_not_supported",201        "not supported by any provider",202        "invalid_request_error",203        "401",204        "403",205        "unauthorized",206        "forbidden",207    )208    if any(marker in message for marker in non_retryable_markers):209        return False210    retryable_markers = (211        "429",212        "rate limit",213        "500",214        "502",215        "503",216        "504",217        "timeout",218        "timed out",219        "temporarily unavailable",220        "service unavailable",221        "model is loading",222        "provider",223        "overloaded",224    )225    return any(marker in message for marker in retryable_markers)226 227 228# ------------------------------------------------------------------------------229# LOCAL OPEN-WEIGHT MODELS  (Phi-3 · BioMistral)230# Supports:231# - hf_router      -> Hugging Face Inference Router232# - local_openai   -> local OpenAI-compatible server such as vLLM233# - transformers   -> direct local transformers loading234# ------------------------------------------------------------------------------235 236def call_open_weight_model(237    backend: str,238    model_id: str,239    prompt: str,240    local_base_url: str,241    local_model_id: str,242) -> str:243    if backend == "hf_router":244        return call_hf_model(model_id, prompt)245    if backend == "local_openai":246        return call_local_openai_model(local_base_url, local_model_id, prompt)247    if backend == "transformers":248        return call_transformers_model(model_id, prompt)249 250    raise ValueError(251        f"Unknown backend '{backend}'. "252        "Use one of: hf_router, local_openai, transformers."253    )254 255 256def call_local_openai_model(base_url: str, model_id: str, prompt: str) -> str:257    from openai import OpenAI258 259    client = OpenAI(260        base_url=base_url,261        api_key=os.getenv("LOCAL_OPENAI_API_KEY", "local"),262    )263    response = client.chat.completions.create(264        model=model_id,265        messages=[{"role": "user", "content": prompt}],266        max_tokens=LOCAL_MAX_NEW_TOKENS,267        temperature=LOCAL_TEMPERATURE,268    )269    text = clean_model_response(response.choices[0].message.content)270    if not text:271        raise RuntimeError(f"{model_id} returned an empty response from {base_url}.")272    return text273 274 275def call_transformers_model(model_id: str, prompt: str) -> str:276    try:277        import torch278        from transformers import AutoModelForCausalLM, AutoTokenizer279    except ImportError as e:280        raise EnvironmentError(281            "Local transformers backend requires torch, transformers, and accelerate.\n"282            "Install with: pip install -r requirements-local.txt"283        ) from e284 285    model_kwargs = _resolve_local_transformers_model_kwargs(torch)286    cache_key = (287        model_id,288        model_kwargs.get("device_map"),289        model_kwargs.get("dtype"),290        LOCAL_ATTN_IMPLEMENTATION,291        LOCAL_TRUST_REMOTE_CODE,292        LOCAL_QUANTIZATION,293    )294    if cache_key not in _TRANSFORMERS_CACHE:295        tokenizer = AutoTokenizer.from_pretrained(296            model_id,297            trust_remote_code=LOCAL_TRUST_REMOTE_CODE,298        )299        model_kwargs["trust_remote_code"] = LOCAL_TRUST_REMOTE_CODE300        if LOCAL_ATTN_IMPLEMENTATION:301            model_kwargs["attn_implementation"] = LOCAL_ATTN_IMPLEMENTATION302 303        model = _load_transformers_model_with_optional_fallback(304            AutoModelForCausalLM,305            model_id,306            model_kwargs,307        )308        if model_kwargs.get("device_map") is None and torch.cuda.is_available():309            model.to("cuda")310        model.eval()311        _TRANSFORMERS_CACHE[cache_key] = (tokenizer, model)312 313    tokenizer, model = _TRANSFORMERS_CACHE[cache_key]314    inputs = _build_transformers_inputs(tokenizer, prompt)315    inputs = _move_inputs_for_generation(model, inputs)316 317    generation_kwargs = {318        "max_new_tokens": LOCAL_MAX_NEW_TOKENS,319        "do_sample": LOCAL_TEMPERATURE > 0,320        "pad_token_id": tokenizer.eos_token_id,321    }322    if LOCAL_TEMPERATURE > 0:323        generation_kwargs["temperature"] = LOCAL_TEMPERATURE324 325    with torch.no_grad():326        output_ids = model.generate(**inputs, **generation_kwargs)327 328    prompt_length = inputs["input_ids"].shape[-1]329    generated_ids = output_ids[0][prompt_length:]330    text = clean_model_response(tokenizer.decode(generated_ids, skip_special_tokens=True))331    if not text:332        raise RuntimeError(f"{model_id} returned an empty local response.")333    return text334 335 336def _resolve_local_transformers_model_kwargs(torch) -> dict:337    """338    Resolve safe local model-loading kwargs for open-weight models.339 340    On GPU machines, allow Accelerate's automatic placement. On CPU-only341    machines, avoid device_map='auto' because it can silently choose disk342    offload, which has caused native Windows crashes during generation.343    """344    device_override = os.getenv("LOCAL_DEVICE_MAP")345    dtype_override = os.getenv("LOCAL_TORCH_DTYPE", "auto")346    kwargs = {}347 348    if device_override:349        requested_device_map = device_override.lower()350        if requested_device_map in ("none", "cpu"):351            device_map = None352        elif requested_device_map == "auto" and not torch.cuda.is_available():353            device_map = None354        else:355            device_map = device_override356    elif torch.cuda.is_available():357        device_map = "auto"358    else:359        device_map = None360 361    if dtype_override != "auto":362        dtype = _resolve_torch_dtype(torch, dtype_override)363    elif torch.cuda.is_available():364        dtype = torch.float16365    else:366        dtype = torch.float32367 368    if device_map is not None:369        kwargs["device_map"] = device_map370    if dtype is not None:371        kwargs["dtype"] = dtype372    quantization_config = _resolve_transformers_quantization_config()373    if quantization_config is not None:374        kwargs["quantization_config"] = quantization_config375    return kwargs376 377 378def _load_transformers_model_with_optional_fallback(model_cls, model_id: str, model_kwargs: dict):379    """Load via Transformers, retrying unquantized if optional quantization fails."""380    try:381        return model_cls.from_pretrained(model_id, **model_kwargs)382    except Exception as e:383        if "quantization_config" not in model_kwargs or not LOCAL_QUANTIZATION_FALLBACK:384            raise385 386        fallback_kwargs = dict(model_kwargs)387        fallback_kwargs.pop("quantization_config", None)388        print(389            f"  Optional local quantization '{LOCAL_QUANTIZATION}' failed for {model_id}; "390            "falling back to the original Transformers loader."391        )392        print(f"  Quantization failure detail: {str(e)[:180]}")393        return model_cls.from_pretrained(model_id, **fallback_kwargs)394 395 396def _resolve_transformers_quantization_config():397    """Return an optional Transformers quantization config, or None."""398    if LOCAL_QUANTIZATION in ("", "none", "false", "0"):399        return None400 401    if LOCAL_QUANTIZATION.startswith("quanto_"):402        try:403            from transformers import QuantoConfig404        except ImportError as e:405            if LOCAL_QUANTIZATION_FALLBACK:406                print(407                    f"  LOCAL_QUANTIZATION={LOCAL_QUANTIZATION} requested, but QuantoConfig "408                    "is unavailable; using the original Transformers loader."409                )410                return None411            raise EnvironmentError(412                "LOCAL_QUANTIZATION requires a Transformers build with QuantoConfig."413            ) from e414 415        weights = LOCAL_QUANTIZATION.removeprefix("quanto_")416        return QuantoConfig(weights=weights)417 418    if LOCAL_QUANTIZATION.startswith("bnb_"):419        try:420            from transformers import BitsAndBytesConfig421        except ImportError as e:422            if LOCAL_QUANTIZATION_FALLBACK:423                print(424                    f"  LOCAL_QUANTIZATION={LOCAL_QUANTIZATION} requested, but "425                    "BitsAndBytesConfig is unavailable; using the original Transformers loader."426                )427                return None428            raise EnvironmentError(429                "LOCAL_QUANTIZATION=bnb_* requires bitsandbytes-compatible Transformers support."430            ) from e431 432        mode = LOCAL_QUANTIZATION.removeprefix("bnb_")433        if mode == "4bit":434            return BitsAndBytesConfig(load_in_4bit=True)435        if mode == "8bit":436            return BitsAndBytesConfig(load_in_8bit=True)437 438    raise ValueError(439        "Unknown LOCAL_QUANTIZATION value. Use none, quanto_int8, quanto_int4, "440        "bnb_8bit, or bnb_4bit."441    )442 443 444def _build_transformers_inputs(tokenizer, prompt: str) -> dict:445    if getattr(tokenizer, "chat_template", None):446        return tokenizer.apply_chat_template(447            [{"role": "user", "content": prompt}],448            tokenize=True,449            return_dict=True,450            return_tensors="pt",451            add_generation_prompt=True,452        )453    return tokenizer(f"User: {prompt}\nAssistant:", return_tensors="pt")454 455 456def _move_inputs_for_generation(model, inputs: dict) -> dict:457    device = None458    device_map = getattr(model, "hf_device_map", None)459    if device_map:460        device = next(461            (462                mapped_device463                for mapped_device in device_map.values()464                if mapped_device not in ("cpu", "disk")465            ),466            None,467        )468 469    if device is None:470        device = getattr(model, "device", None)471 472    if device is None or str(device) == "disk":473        return inputs474 475    return {name: tensor.to(device) for name, tensor in inputs.items()}476 477 478def _resolve_torch_dtype(torch, dtype_name: str):479    if dtype_name == "auto":480        return "auto"481    if dtype_name in ("none", ""):482        return None483    if hasattr(torch, dtype_name):484        return getattr(torch, dtype_name)485    raise ValueError(486        f"Unknown LOCAL_TORCH_DTYPE '{dtype_name}'. "487        "Common values: auto, float16, bfloat16, float32."488    )489 490 491# -- Individual HF model wrappers ----------------------------------------------492 493def call_phi3(prompt: str) -> str:494    """Phi-3 Mini 4K Instruct via the configured open-weight backend."""495    return call_open_weight_model(496        PHI3_BACKEND,497        PHI3_MODEL,498        prompt,499        PHI3_LOCAL_BASE_URL,500        PHI3_LOCAL_MODEL,501    )502 503 504def call_biomistral(prompt: str) -> str:505    """BioMistral 7B SLERP via the configured open-weight backend."""506    return call_open_weight_model(507        BIOMISTRAL_BACKEND,508        BIOMISTRAL_MODEL,509        prompt,510        BIOMISTRAL_LOCAL_BASE_URL,511        BIOMISTRAL_LOCAL_MODEL,512    )513 514 515# ------------------------------------------------------------------------------516# OPENAI API  (GPT-4o -- reinstated per explicit team decision, overriding §9)517#518# §9 of GMASS_Team_Clarifications.md recommended GPT-4o mini (94% cheaper,519# comparable safety-classification performance, ~$5 total for all 1,800520# proprietary calls). The team explicitly chose to reinstate full GPT-4o521# instead, to match the original 5-model lineup. Cost impact: full GPT-4o is522# significantly more per-token than GPT-4o mini -- budget accordingly for the523# 900 GPT-4o calls in a full run; confirm against current OpenAI pricing524# before a production run, as mini's <$5 estimate no longer applies.525#526# To switch back to mini without code changes, set GPT4O_MODEL=gpt-4o-mini527# in .env -- the model_id is fully configurable, only the default changed.528# ------------------------------------------------------------------------------529 530GPT4O_MODEL = os.getenv("GPT4O_MODEL", "gpt-4o")531 532 533def call_gpt4o(prompt: str) -> str:534    """535    GPT-4o via OpenAI API. Reinstated per explicit team decision (see module536    comment above) -- overrides clarifications §9's GPT-4o mini recommendation.537    Requires OPENAI_API_KEY in .env.538    Get key at: platform.openai.com/api-keys539 540    NOTE: function name kept as call_gpt4o / model key kept as "gpt4o" for541    backward compatibility with existing pipeline code, configs, and scored542    output files. The MODEL_ID actually used is controlled by GPT4O_MODEL --543    see constant above and configs/models.yaml.544    """545    if not OPENAI_KEY:546        raise EnvironmentError(547            "OPENAI_API_KEY is missing. Add it to your .env file.\n"548            "Get one at: platform.openai.com/api-keys"549        )550 551    from openai import OpenAI552 553    client = OpenAI(api_key=OPENAI_KEY)554    response = client.chat.completions.create(555        model=GPT4O_MODEL,556        messages=[{"role": "user", "content": prompt}],557        max_tokens=512,558    )559    return clean_model_response(response.choices[0].message.content)560 561 562# ------------------------------------------------------------------------------563# GOOGLE API  (Gemini)564# Uses new google-genai SDK (google-generativeai is deprecated)565# Get key at: aistudio.google.com566# ------------------------------------------------------------------------------567 568def call_gemini(prompt: str) -> str:569    """570    Gemini via Google GenAI API (new SDK).571    Requires GEMINI_API_KEY in .env.572    Defaults to gemini-2.5-flash. Override with GEMINI_MODEL.573    Get key at: aistudio.google.com -> Get API Key574    """575    if not GEMINI_KEY:576        raise EnvironmentError(577            "GEMINI_API_KEY is missing. Add it to your .env file.\n"578            "Get one at: aistudio.google.com -> Get API Key"579        )580 581    from google import genai582 583    client = genai.Client(api_key=GEMINI_KEY)584    models_to_try = [GEMINI_MODEL] + [585        model for model in GEMINI_FALLBACK_MODELS if model != GEMINI_MODEL586    ]587    last_error = None588 589    for model in models_to_try:590        exhausted_retryable_error = False591        for attempt in range(1, GEMINI_RETRIES + 1):592            try:593                response = client.models.generate_content(594                    model=model,595                    contents=prompt,596                )597                text = (response.text or "").strip()598                if not text:599                    raise RuntimeError(f"{model} returned an empty response.")600                return text601            except Exception as e:602                last_error = e603                if not _is_retryable_gemini_error(e):604                    raise605                if attempt == GEMINI_RETRIES:606                    exhausted_retryable_error = True607                    break608 609                delay = GEMINI_RETRY_DELAY * (2 ** (attempt - 1))610                print(611                    f"  Gemini transient error on {model}; "612                    f"retrying in {delay:.1f}s ({attempt}/{GEMINI_RETRIES})..."613                )614                time.sleep(delay)615 616        if exhausted_retryable_error and model != models_to_try[-1]:617            next_model = models_to_try[models_to_try.index(model) + 1]618            print(f"  Gemini fallback: trying {next_model}...")619 620    raise last_error621 622 623def _is_retryable_gemini_error(error: Exception) -> bool:624    """Return True for temporary Gemini API failures worth retrying."""625    message = str(error).lower()626    if _is_non_retryable_gemini_quota_error(error):627        return False628    retryable_markers = (629        "503",630        "unavailable",631        "overloaded",632        "high demand",633        "500",634        "internal",635        "504",636        "deadline_exceeded",637        "429",638        "resource_exhausted",639    )640    return any(marker in message for marker in retryable_markers)641 642 643def _is_non_retryable_gemini_quota_error(error: Exception) -> bool:644    """645    Return True for hard quota failures that retries/fallbacks cannot fix.646 647    Gemini also reports short rate limits as 429 RESOURCE_EXHAUSTED, and those648    are worth retrying. The free-tier "limit: 0" / daily quota messages from649    the API are different: every retry just waits and then fails again.650    """651    message = str(error).lower()652    hard_quota_markers = (653        "free_tier_requests, limit: 0",654        "free_tier_input_token_count, limit: 0",655        "generate requests per day",656        "generate_content_free_tier_requests",657        "check your plan and billing details",658    )659    return "429" in message and any(marker in message for marker in hard_quota_markers)660 661 662# ------------------------------------------------------------------------------663# UNIFIED DISPATCHER664# ------------------------------------------------------------------------------665 666MODEL_FUNCTIONS = {667    "gpt4o":      call_gpt4o,668    "gemini":     call_gemini,669    "phi3":       call_phi3,670    "biomistral": call_biomistral,671}672 673VALID_MODELS = list(MODEL_FUNCTIONS.keys())674 675 676def call_model(model_name: str, prompt: str) -> str:677    """678    Universal entry point. Use this from your scoring pipeline.679 680    Args:681        model_name : one of "gpt4o", "gemini", "phi3", "biomistral"682        prompt     : the text prompt to send683 684    Returns:685        The model's response as a plain string.686 687    Example:688        from models.router import call_model689        response = call_model("gemini", "What are symptoms of malaria?")690    """691    model_name = normalize_model_name(model_name)692    fn = MODEL_FUNCTIONS.get(model_name)693    if fn is None:694        raise ValueError(695            f"Unknown model: '{model_name}'.\n"696            f"Valid options: {VALID_MODELS}"697        )698    return fn(prompt)699 700 701 702