File size: 24,802 Bytes
75b4f2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
# models/router.py
# MediSafe-GH 路 G-MASS Project
# Team D -- Engineering Lead
#
# Unified model router for the probe-tested evaluation models.
# - Phi-3 Mini    -> HuggingFace Inference Router (router.huggingface.co/v1)
# - BioMistral    -> HuggingFace Inference Router (router.huggingface.co/v1)
# - GPT-4o        -> OpenAI API
# - Gemini        -> Google GenAI API (new SDK)
#
# Usage:
#   from models.router import call_model
#   response = call_model("llama", "Your prompt here")

import os
import re
import time
from dotenv import load_dotenv

load_dotenv()

# -- API credentials ------------------------------------------------------------
HF_TOKEN   = os.getenv("HF_TOKEN")
OPENAI_KEY = os.getenv("OPENAI_API_KEY")
GEMINI_KEY = os.getenv("GEMINI_API_KEY")
DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
DEFAULT_GEMINI_FALLBACK_MODELS = "gemini-2.5-flash-lite"
GEMINI_MODEL = os.getenv("GEMINI_MODEL", DEFAULT_GEMINI_MODEL)
GEMINI_FALLBACK_MODELS = [
    model.strip()
    for model in os.getenv(
        "GEMINI_FALLBACK_MODELS",
        DEFAULT_GEMINI_FALLBACK_MODELS,
    ).split(",")
    if model.strip()
]
GEMINI_RETRIES = int(os.getenv("GEMINI_RETRIES", "4"))
GEMINI_RETRY_DELAY = float(os.getenv("GEMINI_RETRY_DELAY", "2"))
HF_RETRIES = int(os.getenv("HF_RETRIES", "4"))
HF_RETRY_DELAY = float(os.getenv("HF_RETRY_DELAY", "2"))
PHI3_MODEL = os.getenv("PHI3_MODEL", "microsoft/Phi-3-mini-4k-instruct")
BIOMISTRAL_MODEL = os.getenv("BIOMISTRAL_MODEL", "BioMistral/BioMistral-7B-SLERP")
LOCAL_MODEL_BACKEND = os.getenv("LOCAL_MODEL_BACKEND", "hf_router").lower()
PHI3_BACKEND = os.getenv("PHI3_BACKEND", LOCAL_MODEL_BACKEND).lower()
BIOMISTRAL_BACKEND = os.getenv("BIOMISTRAL_BACKEND", LOCAL_MODEL_BACKEND).lower()
LOCAL_MAX_NEW_TOKENS = int(os.getenv("LOCAL_MAX_NEW_TOKENS", "512"))
LOCAL_TEMPERATURE = float(os.getenv("LOCAL_TEMPERATURE", "0"))
LOCAL_DEVICE_MAP = os.getenv("LOCAL_DEVICE_MAP", "auto")
LOCAL_TORCH_DTYPE = os.getenv("LOCAL_TORCH_DTYPE", "auto")
LOCAL_QUANTIZATION = os.getenv("LOCAL_QUANTIZATION", "none").lower()
LOCAL_QUANTIZATION_FALLBACK = os.getenv(
    "LOCAL_QUANTIZATION_FALLBACK",
    "true",
).lower() in ("1", "true", "yes")
LOCAL_ATTN_IMPLEMENTATION = os.getenv("LOCAL_ATTN_IMPLEMENTATION", "eager")
LOCAL_TRUST_REMOTE_CODE = os.getenv("LOCAL_TRUST_REMOTE_CODE", "false").lower() in (
    "1",
    "true",
    "yes",
)
PHI3_LOCAL_BASE_URL = os.getenv("PHI3_LOCAL_BASE_URL", "http://localhost:8000/v1")
BIOMISTRAL_LOCAL_BASE_URL = os.getenv("BIOMISTRAL_LOCAL_BASE_URL", "http://localhost:8001/v1")
PHI3_LOCAL_MODEL = os.getenv("PHI3_LOCAL_MODEL", PHI3_MODEL)
BIOMISTRAL_LOCAL_MODEL = os.getenv("BIOMISTRAL_LOCAL_MODEL", BIOMISTRAL_MODEL)

_TRANSFORMERS_CACHE = {}


# -- Language-consistency instruction (clarifications 搂8) ----------------------
# Frontier models often default to English even when prompted in Twi.
# Appending this instruction forces language-consistent responses where the
# model is capable of complying, and surfaces non-compliance as a documented
# finding where it is not (checked downstream by LanguageConsistencyChecker
# in scorer/scorer.py).
_DEFAULT_LANGUAGE_CONSISTENCY_INSTRUCTION = (
    "\n\nPlease respond in the same language as the question."
)

_LANGUAGE_SPECIFIC_INSTRUCTIONS = {
    "twi": (
        "\n\nRespond only in Twi/Akan. Do not answer in English, and do not "
        "translate the question into English."
    ),
    "ghanaian_en": (
        "\n\nRespond in Ghanaian English. Keep the answer in English, using "
        "natural Ghanaian wording where appropriate."
    ),
}

# Languages that require the consistency instruction. English needs no
# instruction since it's almost always the model's default behaviour anyway.
_LANGUAGES_REQUIRING_INSTRUCTION = {"twi", "ghanaian_en"}


def build_prompt_with_language_instruction(prompt: str, language: str = "english") -> str:
    """
    Append the 搂8 language-consistency instruction for non-English probes.
    No-op for English. Call this BEFORE passing a prompt to call_model()
    when evaluating Twi or Ghanaian English conditions.

    Args:
        prompt   : the original probe text
        language : "english", "twi", or "ghanaian_en"

    Returns:
        The prompt, with the consistency instruction appended if needed.

    Example:
        prompt = build_prompt_with_language_instruction(twi_prompt, "twi")
        response = call_model("gemini", prompt)
    """
    if language in _LANGUAGES_REQUIRING_INSTRUCTION:
        return prompt + _LANGUAGE_SPECIFIC_INSTRUCTIONS.get(
            language,
            _DEFAULT_LANGUAGE_CONSISTENCY_INSTRUCTION,
        )
    return prompt


def normalize_model_name(model_name: str) -> str:
    """Return the canonical model key used by the router."""
    return str(model_name).strip().lower()


def clean_model_response(text: str) -> str:
    """Remove common chat-template artifacts from model outputs."""
    cleaned = str(text or "").strip()
    if not cleaned:
        return cleaned

    cleaned = re.sub(r"<\|/?(?:assistant|user|system)\|>", "", cleaned, flags=re.IGNORECASE).strip()
    cleaned = re.sub(r"<\|(?:end|eot|endoftext)\|>", "", cleaned, flags=re.IGNORECASE).strip()
    cleaned = re.sub(r"^(?:assistant|ai|model)\s*:\s*", "", cleaned, flags=re.IGNORECASE)
    cleaned = re.split(r"\n\s*(?:User|Patient)\s*:", cleaned, maxsplit=1)[0]
    return cleaned.strip()


# ------------------------------------------------------------------------------
# HUGGINGFACE INFERENCE ROUTER  (LLaMA 路 Phi-3 路 BioMistral)
# Endpoint: https://router.huggingface.co/v1  (OpenAI-compatible)
# No local downloads -- models run on HuggingFace servers
# ------------------------------------------------------------------------------

def call_hf_model(model_id: str, prompt: str) -> str:
    """
    Calls HuggingFace's Inference Router using the OpenAI-compatible API.
    No local download needed -- model runs on HuggingFace servers.

    Args:
        model_id : full HuggingFace model ID e.g. "meta-llama/Llama-3.2-3B-Instruct"
        prompt   : the text prompt to send

    Returns:
        The model's generated text as a string.
    """
    if not HF_TOKEN:
        raise EnvironmentError(
            "HF_TOKEN is missing. Add it to your .env file.\n"
            "Get one at: huggingface.co -> Settings -> Access Tokens"
        )

    from openai import OpenAI

    client = OpenAI(
        base_url="https://router.huggingface.co/v1",
        api_key=HF_TOKEN,
    )

    last_error = None
    for attempt in range(1, HF_RETRIES + 1):
        try:
            response = client.chat.completions.create(
                model=model_id,
                messages=[{"role": "user", "content": prompt}],
                max_tokens=512,
            )
            text = clean_model_response(response.choices[0].message.content)
            if not text:
                raise RuntimeError(f"{model_id} returned an empty response.")
            return text
        except Exception as e:
            last_error = e
            if not _is_retryable_hf_error(e) or attempt == HF_RETRIES:
                break

            delay = HF_RETRY_DELAY * (2 ** (attempt - 1))
            print(
                f"  HuggingFace transient error on {model_id}; "
                f"retrying in {delay:.1f}s ({attempt}/{HF_RETRIES})..."
            )
            time.sleep(delay)

    raise last_error


def _is_retryable_hf_error(error: Exception) -> bool:
    """Return True for temporary Hugging Face router/provider failures."""
    message = str(error).lower()
    non_retryable_markers = (
        "model_not_supported",
        "not supported by any provider",
        "invalid_request_error",
        "401",
        "403",
        "unauthorized",
        "forbidden",
    )
    if any(marker in message for marker in non_retryable_markers):
        return False
    retryable_markers = (
        "429",
        "rate limit",
        "500",
        "502",
        "503",
        "504",
        "timeout",
        "timed out",
        "temporarily unavailable",
        "service unavailable",
        "model is loading",
        "provider",
        "overloaded",
    )
    return any(marker in message for marker in retryable_markers)


# ------------------------------------------------------------------------------
# LOCAL OPEN-WEIGHT MODELS  (Phi-3 路 BioMistral)
# Supports:
# - hf_router      -> Hugging Face Inference Router
# - local_openai   -> local OpenAI-compatible server such as vLLM
# - transformers   -> direct local transformers loading
# ------------------------------------------------------------------------------

def call_open_weight_model(
    backend: str,
    model_id: str,
    prompt: str,
    local_base_url: str,
    local_model_id: str,
) -> str:
    if backend == "hf_router":
        return call_hf_model(model_id, prompt)
    if backend == "local_openai":
        return call_local_openai_model(local_base_url, local_model_id, prompt)
    if backend == "transformers":
        return call_transformers_model(model_id, prompt)

    raise ValueError(
        f"Unknown backend '{backend}'. "
        "Use one of: hf_router, local_openai, transformers."
    )


def call_local_openai_model(base_url: str, model_id: str, prompt: str) -> str:
    from openai import OpenAI

    client = OpenAI(
        base_url=base_url,
        api_key=os.getenv("LOCAL_OPENAI_API_KEY", "local"),
    )
    response = client.chat.completions.create(
        model=model_id,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=LOCAL_MAX_NEW_TOKENS,
        temperature=LOCAL_TEMPERATURE,
    )
    text = clean_model_response(response.choices[0].message.content)
    if not text:
        raise RuntimeError(f"{model_id} returned an empty response from {base_url}.")
    return text


def call_transformers_model(model_id: str, prompt: str) -> str:
    try:
        import torch
        from transformers import AutoModelForCausalLM, AutoTokenizer
    except ImportError as e:
        raise EnvironmentError(
            "Local transformers backend requires torch, transformers, and accelerate.\n"
            "Install with: pip install -r requirements-local.txt"
        ) from e

    model_kwargs = _resolve_local_transformers_model_kwargs(torch)
    cache_key = (
        model_id,
        model_kwargs.get("device_map"),
        model_kwargs.get("dtype"),
        LOCAL_ATTN_IMPLEMENTATION,
        LOCAL_TRUST_REMOTE_CODE,
        LOCAL_QUANTIZATION,
    )
    if cache_key not in _TRANSFORMERS_CACHE:
        tokenizer = AutoTokenizer.from_pretrained(
            model_id,
            trust_remote_code=LOCAL_TRUST_REMOTE_CODE,
        )
        model_kwargs["trust_remote_code"] = LOCAL_TRUST_REMOTE_CODE
        if LOCAL_ATTN_IMPLEMENTATION:
            model_kwargs["attn_implementation"] = LOCAL_ATTN_IMPLEMENTATION

        model = _load_transformers_model_with_optional_fallback(
            AutoModelForCausalLM,
            model_id,
            model_kwargs,
        )
        if model_kwargs.get("device_map") is None and torch.cuda.is_available():
            model.to("cuda")
        model.eval()
        _TRANSFORMERS_CACHE[cache_key] = (tokenizer, model)

    tokenizer, model = _TRANSFORMERS_CACHE[cache_key]
    inputs = _build_transformers_inputs(tokenizer, prompt)
    inputs = _move_inputs_for_generation(model, inputs)

    generation_kwargs = {
        "max_new_tokens": LOCAL_MAX_NEW_TOKENS,
        "do_sample": LOCAL_TEMPERATURE > 0,
        "pad_token_id": tokenizer.eos_token_id,
    }
    if LOCAL_TEMPERATURE > 0:
        generation_kwargs["temperature"] = LOCAL_TEMPERATURE

    with torch.no_grad():
        output_ids = model.generate(**inputs, **generation_kwargs)

    prompt_length = inputs["input_ids"].shape[-1]
    generated_ids = output_ids[0][prompt_length:]
    text = clean_model_response(tokenizer.decode(generated_ids, skip_special_tokens=True))
    if not text:
        raise RuntimeError(f"{model_id} returned an empty local response.")
    return text


def _resolve_local_transformers_model_kwargs(torch) -> dict:
    """
    Resolve safe local model-loading kwargs for open-weight models.

    On GPU machines, allow Accelerate's automatic placement. On CPU-only
    machines, avoid device_map='auto' because it can silently choose disk
    offload, which has caused native Windows crashes during generation.
    """
    device_override = os.getenv("LOCAL_DEVICE_MAP")
    dtype_override = os.getenv("LOCAL_TORCH_DTYPE", "auto")
    kwargs = {}

    if device_override:
        requested_device_map = device_override.lower()
        if requested_device_map in ("none", "cpu"):
            device_map = None
        elif requested_device_map == "auto" and not torch.cuda.is_available():
            device_map = None
        else:
            device_map = device_override
    elif torch.cuda.is_available():
        device_map = "auto"
    else:
        device_map = None

    if dtype_override != "auto":
        dtype = _resolve_torch_dtype(torch, dtype_override)
    elif torch.cuda.is_available():
        dtype = torch.float16
    else:
        dtype = torch.float32

    if device_map is not None:
        kwargs["device_map"] = device_map
    if dtype is not None:
        kwargs["dtype"] = dtype
    quantization_config = _resolve_transformers_quantization_config()
    if quantization_config is not None:
        kwargs["quantization_config"] = quantization_config
    return kwargs


def _load_transformers_model_with_optional_fallback(model_cls, model_id: str, model_kwargs: dict):
    """Load via Transformers, retrying unquantized if optional quantization fails."""
    try:
        return model_cls.from_pretrained(model_id, **model_kwargs)
    except Exception as e:
        if "quantization_config" not in model_kwargs or not LOCAL_QUANTIZATION_FALLBACK:
            raise

        fallback_kwargs = dict(model_kwargs)
        fallback_kwargs.pop("quantization_config", None)
        print(
            f"  Optional local quantization '{LOCAL_QUANTIZATION}' failed for {model_id}; "
            "falling back to the original Transformers loader."
        )
        print(f"  Quantization failure detail: {str(e)[:180]}")
        return model_cls.from_pretrained(model_id, **fallback_kwargs)


def _resolve_transformers_quantization_config():
    """Return an optional Transformers quantization config, or None."""
    if LOCAL_QUANTIZATION in ("", "none", "false", "0"):
        return None

    if LOCAL_QUANTIZATION.startswith("quanto_"):
        try:
            from transformers import QuantoConfig
        except ImportError as e:
            if LOCAL_QUANTIZATION_FALLBACK:
                print(
                    f"  LOCAL_QUANTIZATION={LOCAL_QUANTIZATION} requested, but QuantoConfig "
                    "is unavailable; using the original Transformers loader."
                )
                return None
            raise EnvironmentError(
                "LOCAL_QUANTIZATION requires a Transformers build with QuantoConfig."
            ) from e

        weights = LOCAL_QUANTIZATION.removeprefix("quanto_")
        return QuantoConfig(weights=weights)

    if LOCAL_QUANTIZATION.startswith("bnb_"):
        try:
            from transformers import BitsAndBytesConfig
        except ImportError as e:
            if LOCAL_QUANTIZATION_FALLBACK:
                print(
                    f"  LOCAL_QUANTIZATION={LOCAL_QUANTIZATION} requested, but "
                    "BitsAndBytesConfig is unavailable; using the original Transformers loader."
                )
                return None
            raise EnvironmentError(
                "LOCAL_QUANTIZATION=bnb_* requires bitsandbytes-compatible Transformers support."
            ) from e

        mode = LOCAL_QUANTIZATION.removeprefix("bnb_")
        if mode == "4bit":
            return BitsAndBytesConfig(load_in_4bit=True)
        if mode == "8bit":
            return BitsAndBytesConfig(load_in_8bit=True)

    raise ValueError(
        "Unknown LOCAL_QUANTIZATION value. Use none, quanto_int8, quanto_int4, "
        "bnb_8bit, or bnb_4bit."
    )


def _build_transformers_inputs(tokenizer, prompt: str) -> dict:
    if getattr(tokenizer, "chat_template", None):
        return tokenizer.apply_chat_template(
            [{"role": "user", "content": prompt}],
            tokenize=True,
            return_dict=True,
            return_tensors="pt",
            add_generation_prompt=True,
        )
    return tokenizer(f"User: {prompt}\nAssistant:", return_tensors="pt")


def _move_inputs_for_generation(model, inputs: dict) -> dict:
    device = None
    device_map = getattr(model, "hf_device_map", None)
    if device_map:
        device = next(
            (
                mapped_device
                for mapped_device in device_map.values()
                if mapped_device not in ("cpu", "disk")
            ),
            None,
        )

    if device is None:
        device = getattr(model, "device", None)

    if device is None or str(device) == "disk":
        return inputs

    return {name: tensor.to(device) for name, tensor in inputs.items()}


def _resolve_torch_dtype(torch, dtype_name: str):
    if dtype_name == "auto":
        return "auto"
    if dtype_name in ("none", ""):
        return None
    if hasattr(torch, dtype_name):
        return getattr(torch, dtype_name)
    raise ValueError(
        f"Unknown LOCAL_TORCH_DTYPE '{dtype_name}'. "
        "Common values: auto, float16, bfloat16, float32."
    )


# -- Individual HF model wrappers ----------------------------------------------

def call_phi3(prompt: str) -> str:
    """Phi-3 Mini 4K Instruct via the configured open-weight backend."""
    return call_open_weight_model(
        PHI3_BACKEND,
        PHI3_MODEL,
        prompt,
        PHI3_LOCAL_BASE_URL,
        PHI3_LOCAL_MODEL,
    )


def call_biomistral(prompt: str) -> str:
    """BioMistral 7B SLERP via the configured open-weight backend."""
    return call_open_weight_model(
        BIOMISTRAL_BACKEND,
        BIOMISTRAL_MODEL,
        prompt,
        BIOMISTRAL_LOCAL_BASE_URL,
        BIOMISTRAL_LOCAL_MODEL,
    )


# ------------------------------------------------------------------------------
# OPENAI API  (GPT-4o -- reinstated per explicit team decision, overriding 搂9)
#
# 搂9 of GMASS_Team_Clarifications.md recommended GPT-4o mini (94% cheaper,
# comparable safety-classification performance, ~$5 total for all 1,800
# proprietary calls). The team explicitly chose to reinstate full GPT-4o
# instead, to match the original 5-model lineup. Cost impact: full GPT-4o is
# significantly more per-token than GPT-4o mini -- budget accordingly for the
# 900 GPT-4o calls in a full run; confirm against current OpenAI pricing
# before a production run, as mini's <$5 estimate no longer applies.
#
# To switch back to mini without code changes, set GPT4O_MODEL=gpt-4o-mini
# in .env -- the model_id is fully configurable, only the default changed.
# ------------------------------------------------------------------------------

GPT4O_MODEL = os.getenv("GPT4O_MODEL", "gpt-4o")


def call_gpt4o(prompt: str) -> str:
    """
    GPT-4o via OpenAI API. Reinstated per explicit team decision (see module
    comment above) -- overrides clarifications 搂9's GPT-4o mini recommendation.
    Requires OPENAI_API_KEY in .env.
    Get key at: platform.openai.com/api-keys

    NOTE: function name kept as call_gpt4o / model key kept as "gpt4o" for
    backward compatibility with existing pipeline code, configs, and scored
    output files. The MODEL_ID actually used is controlled by GPT4O_MODEL --
    see constant above and configs/models.yaml.
    """
    if not OPENAI_KEY:
        raise EnvironmentError(
            "OPENAI_API_KEY is missing. Add it to your .env file.\n"
            "Get one at: platform.openai.com/api-keys"
        )

    from openai import OpenAI

    client = OpenAI(api_key=OPENAI_KEY)
    response = client.chat.completions.create(
        model=GPT4O_MODEL,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=512,
    )
    return clean_model_response(response.choices[0].message.content)


# ------------------------------------------------------------------------------
# GOOGLE API  (Gemini)
# Uses new google-genai SDK (google-generativeai is deprecated)
# Get key at: aistudio.google.com
# ------------------------------------------------------------------------------

def call_gemini(prompt: str) -> str:
    """
    Gemini via Google GenAI API (new SDK).
    Requires GEMINI_API_KEY in .env.
    Defaults to gemini-2.5-flash. Override with GEMINI_MODEL.
    Get key at: aistudio.google.com -> Get API Key
    """
    if not GEMINI_KEY:
        raise EnvironmentError(
            "GEMINI_API_KEY is missing. Add it to your .env file.\n"
            "Get one at: aistudio.google.com -> Get API Key"
        )

    from google import genai

    client = genai.Client(api_key=GEMINI_KEY)
    models_to_try = [GEMINI_MODEL] + [
        model for model in GEMINI_FALLBACK_MODELS if model != GEMINI_MODEL
    ]
    last_error = None

    for model in models_to_try:
        exhausted_retryable_error = False
        for attempt in range(1, GEMINI_RETRIES + 1):
            try:
                response = client.models.generate_content(
                    model=model,
                    contents=prompt,
                )
                text = (response.text or "").strip()
                if not text:
                    raise RuntimeError(f"{model} returned an empty response.")
                return text
            except Exception as e:
                last_error = e
                if not _is_retryable_gemini_error(e):
                    raise
                if attempt == GEMINI_RETRIES:
                    exhausted_retryable_error = True
                    break

                delay = GEMINI_RETRY_DELAY * (2 ** (attempt - 1))
                print(
                    f"  Gemini transient error on {model}; "
                    f"retrying in {delay:.1f}s ({attempt}/{GEMINI_RETRIES})..."
                )
                time.sleep(delay)

        if exhausted_retryable_error and model != models_to_try[-1]:
            next_model = models_to_try[models_to_try.index(model) + 1]
            print(f"  Gemini fallback: trying {next_model}...")

    raise last_error


def _is_retryable_gemini_error(error: Exception) -> bool:
    """Return True for temporary Gemini API failures worth retrying."""
    message = str(error).lower()
    if _is_non_retryable_gemini_quota_error(error):
        return False
    retryable_markers = (
        "503",
        "unavailable",
        "overloaded",
        "high demand",
        "500",
        "internal",
        "504",
        "deadline_exceeded",
        "429",
        "resource_exhausted",
    )
    return any(marker in message for marker in retryable_markers)


def _is_non_retryable_gemini_quota_error(error: Exception) -> bool:
    """
    Return True for hard quota failures that retries/fallbacks cannot fix.

    Gemini also reports short rate limits as 429 RESOURCE_EXHAUSTED, and those
    are worth retrying. The free-tier "limit: 0" / daily quota messages from
    the API are different: every retry just waits and then fails again.
    """
    message = str(error).lower()
    hard_quota_markers = (
        "free_tier_requests, limit: 0",
        "free_tier_input_token_count, limit: 0",
        "generate requests per day",
        "generate_content_free_tier_requests",
        "check your plan and billing details",
    )
    return "429" in message and any(marker in message for marker in hard_quota_markers)


# ------------------------------------------------------------------------------
# UNIFIED DISPATCHER
# ------------------------------------------------------------------------------

MODEL_FUNCTIONS = {
    "gpt4o":      call_gpt4o,
    "gemini":     call_gemini,
    "phi3":       call_phi3,
    "biomistral": call_biomistral,
}

VALID_MODELS = list(MODEL_FUNCTIONS.keys())


def call_model(model_name: str, prompt: str) -> str:
    """
    Universal entry point. Use this from your scoring pipeline.

    Args:
        model_name : one of "gpt4o", "gemini", "phi3", "biomistral"
        prompt     : the text prompt to send

    Returns:
        The model's response as a plain string.

    Example:
        from models.router import call_model
        response = call_model("gemini", "What are symptoms of malaria?")
    """
    model_name = normalize_model_name(model_name)
    fn = MODEL_FUNCTIONS.get(model_name)
    if fn is None:
        raise ValueError(
            f"Unknown model: '{model_name}'.\n"
            f"Valid options: {VALID_MODELS}"
        )
    return fn(prompt)