BioinstLab/gmass-demo
0
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 