File size: 13,733 Bytes
c2b7494
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Shared model clients for generative JSON calls and Jev typed decisions.

Automatic provider selection preserves the original Anthropic-then-OpenAI
precedence; Google is selected explicitly by experiments that require it.
"""

from __future__ import annotations

import json
import os
import re
import time
import uuid
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from src.utils.logging import get_logger

logger = get_logger(__name__)

_JSON_BLOCK_RE = re.compile(r"\{.*\}", re.DOTALL)

DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-8"
DEFAULT_OPENAI_MODEL = "gpt-6-luna"
DEFAULT_GOOGLE_MODEL = "gemini-3-flash-preview"
DEFAULT_JEV_MODEL = "jev-latest"
DEFAULT_JEV_API_URL = "https://api.typesafe.ai/v1/systemone"

_PLACEHOLDER_KEY_VALUES = {"", "your-api-key-here"}


class LLMCallError(RuntimeError):
    """Raised when the LLM call fails or returns unparsable output after retries."""


def _has_real_key(env_var: str) -> bool:
    """Return True if *env_var* is set to a non-empty, non-placeholder value.

    Guards against `.env` files copied from `.env.example` where a key is
    present but still holds the literal placeholder text.
    """
    value = os.environ.get(env_var, "").strip()
    return value.lower() not in _PLACEHOLDER_KEY_VALUES


def resolve_provider(provider: str = "auto") -> str:
    """Resolve the LLM provider to use, based on explicit choice or available API keys.

    Parameters
    ----------
    provider : str
        One of ``"auto"``, ``"anthropic"``, ``"openai"``, ``"google"``, or ``"jev"``.

    Returns
    -------
    str
        The resolved provider name.
    """
    provider = (provider or "auto").lower()
    if provider in {"anthropic", "openai", "google", "jev", "openai_decisions", "laya", "jpt", "mlx"}:
        return provider
    if provider != "auto":
        raise LLMCallError(
            f"Unknown LLM provider: {provider!r}. "
            "Use 'auto', 'anthropic', 'openai', 'google', or 'jev'."
        )

    if _has_real_key("ANTHROPIC_API_KEY"):
        return "anthropic"
    if _has_real_key("OPENAI_API_KEY"):
        return "openai"
    if _has_real_key("JEV_API_KEY"):
        return "jev"
    raise LLMCallError(
        "No LLM API key found. Set ANTHROPIC_API_KEY and/or OPENAI_API_KEY in your .env file."
    )


def default_model_for(provider: str) -> str:
    """Return the default model identifier for the given provider."""
    if provider == "anthropic":
        return DEFAULT_ANTHROPIC_MODEL
    if provider == "google":
        return DEFAULT_GOOGLE_MODEL
    if provider == "jev":
        return DEFAULT_JEV_MODEL
    if provider in {"laya", "jpt", "mlx"}:
        return {"laya": "multilingual", "jpt": "kirp/jpt-4b",
                "mlx": "mlx-community/Qwen3.5-9B-8bit"}[provider]
    return DEFAULT_OPENAI_MODEL


def _get_anthropic_client():
    """Lazily construct the Anthropic client so importing this module does not
    require an API key to be set (e.g. for unit tests that mock calls)."""
    import anthropic

    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        raise LLMCallError("ANTHROPIC_API_KEY environment variable is not set.")
    return anthropic.Anthropic(api_key=api_key)


def _get_openai_client():
    """Lazily construct the OpenAI client so importing this module does not
    require an API key to be set (e.g. for unit tests that mock calls)."""
    import openai

    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        raise LLMCallError("OPENAI_API_KEY environment variable is not set.")
    return openai.OpenAI(api_key=api_key)


def _get_google_client():
    """Lazily construct the Google Gen AI client."""
    from google import genai

    api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
    if not api_key:
        raise LLMCallError("GOOGLE_API_KEY or GEMINI_API_KEY environment variable is not set.")
    return genai.Client(api_key=api_key)


def extract_json(text: str) -> dict[str, Any]:
    """Extract and parse the first JSON object found in *text*.

    Handles cases where the model wraps JSON in markdown code fences or adds
    surrounding commentary despite instructions not to.
    """
    text = text.strip()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    match = _JSON_BLOCK_RE.search(text)
    if match:
        return json.loads(match.group(0))

    raise ValueError(f"Could not extract JSON from LLM response: {text[:200]!r}")


def _call_anthropic(system_prompt: str, user_prompt: str, model: str, max_tokens: int) -> str:
    client = _get_anthropic_client()
    response = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        system=system_prompt,
        messages=[{"role": "user", "content": user_prompt}],
    )
    return "".join(
        block.text for block in response.content if getattr(block, "type", None) == "text"
    )


_REASONING_MODEL_PREFIXES = ("gpt-5", "gpt-6", "o1", "o3", "o4")


def _is_reasoning_model(model: str) -> bool:
    """Whether *model* is an OpenAI reasoning model (GPT-5/6 and o-series).

    Reasoning models spend part of their completion token budget on hidden
    reasoning tokens before producing visible output, and support a
    ``reasoning_effort`` parameter to control that trade-off. Non-reasoning
    models (e.g. gpt-4o) don't accept that parameter, so it must only be sent
    for models in this family.
    """
    return model.lower().startswith(_REASONING_MODEL_PREFIXES)


def _call_openai(
    system_prompt: str,
    user_prompt: str,
    model: str,
    max_tokens: int,
    reasoning_effort: str | None = None,
) -> str:
    client = _get_openai_client()
    kwargs: dict[str, Any] = {}
    if reasoning_effort and _is_reasoning_model(model):
        kwargs["reasoning_effort"] = reasoning_effort

    response = client.chat.completions.create(
        model=model,
        max_completion_tokens=max_tokens,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        **kwargs,
    )
    choice = response.choices[0]
    content = choice.message.content or ""
    if not content.strip():
        # Most commonly caused by a reasoning model spending its entire
        # max_completion_tokens budget on hidden reasoning, leaving nothing
        # for the visible answer (finish_reason == "length" in that case).
        raise ValueError(
            f"Empty response content from model '{model}' "
            f"(finish_reason={choice.finish_reason!r}, usage={response.usage!r}). "
            "Consider increasing max_tokens or lowering reasoning_effort."
        )
    return content


def _call_google(system_prompt: str, user_prompt: str, model: str, max_tokens: int) -> str:
    from google.genai import types

    client = _get_google_client()
    response = client.models.generate_content(
        model=model,
        contents=user_prompt,
        config=types.GenerateContentConfig(
            system_instruction=system_prompt,
            response_mime_type="application/json",
            max_output_tokens=max_tokens,
        ),
    )
    text = response.text or ""
    if not text.strip():
        raise ValueError(f"Empty response content from Google model '{model}'.")
    return text


def call_jev_decisions(
    state: str,
    questions: dict[str, dict[str, Any]],
    model: str = DEFAULT_JEV_MODEL,
    max_retries: int = 3,
) -> dict[str, Any]:
    """Submit typed questions to Jev and return its structured response.

    Jev is not a generative chat model, so it does not use
    :func:`call_llm_json`. A request shares one phenotype state across up to 20
    candidate-classification questions and receives declared labels plus
    probability distributions.
    """
    api_key = (
        os.environ.get("TYPESAFE_API_KEY", "").strip()
        or os.environ.get("JEV_API_KEY", "").strip()
    )
    if not api_key:
        raise LLMCallError(
            "TYPESAFE_API_KEY or JEV_API_KEY environment variable is not set."
        )
    if not questions or len(questions) > 20:
        raise ValueError("Jev requests must contain between 1 and 20 questions.")

    url = os.environ.get("JEV_API_URL", DEFAULT_JEV_API_URL).strip() or DEFAULT_JEV_API_URL
    payload = json.dumps(
        {"model": model, "state": state, "questions": questions},
        separators=(",", ":"),
    ).encode("utf-8")
    idempotency_key = str(uuid.uuid5(uuid.NAMESPACE_URL, payload.decode("utf-8")))
    retryable_statuses = {429, 502, 503, 504, 529}
    last_error: Exception | None = None

    for attempt in range(1, max_retries + 1):
        request = Request(
            url,
            data=payload,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Accept": "application/json",
                "Idempotency-Key": idempotency_key,
            },
        )
        try:
            with urlopen(request, timeout=60) as response:
                result = json.loads(response.read().decode("utf-8"))
            if not isinstance(result.get("answers"), dict):
                raise ValueError("Jev response is missing an 'answers' object.")
            return result
        except HTTPError as exc:
            try:
                error_body = exc.read().decode("utf-8", errors="replace")[:500]
            except Exception:  # noqa: BLE001 - preserve the original HTTP status
                error_body = ""
            last_error = RuntimeError(
                f"Jev HTTP {exc.code} from {url}"
                + (f": {error_body}" if error_body else "")
            )
            if exc.code not in retryable_statuses or attempt >= max_retries:
                break
            retry_after = exc.headers.get("Retry-After") if exc.headers else None
            delay = min(float(retry_after), 30.0) if retry_after else min(2 ** (attempt - 1), 8)
            time.sleep(delay)
        except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
            last_error = exc
            if attempt < max_retries:
                time.sleep(min(2 ** (attempt - 1), 8))

    raise LLMCallError(
        f"Jev decision call failed after {max_retries} attempts: {last_error}"
    ) from last_error


def call_llm_json(
    system_prompt: str,
    user_prompt: str,
    provider: str = "auto",
    model: str | None = None,
    max_retries: int = 3,
    max_tokens: int = 8192,
    reasoning_effort: str | None = None,
    thinking_budget: int | None = None,
) -> dict[str, Any]:
    """Call the configured LLM provider and parse a JSON object from the response,
    retrying on transient failures or malformed JSON output.

    Parameters
    ----------
    system_prompt : str
        System prompt establishing the LLM's role and output format.
    user_prompt : str
        The task-specific user prompt.
    provider : str
        ``"auto"``, ``"anthropic"``, ``"openai"``, or ``"google"``. ``"auto"`` selects
        Anthropic if ``ANTHROPIC_API_KEY`` is set, otherwise OpenAI.
    model : str, optional
        Model identifier. Defaults to a sensible default for the resolved provider.
    max_retries : int
        Number of attempts before raising ``LLMCallError``.
    max_tokens : int
        Maximum completion tokens to generate. For OpenAI reasoning models
        (gpt-5/o1/o3/o4), this budget is shared with hidden reasoning tokens,
        so it should be generous enough to leave room for the visible answer.
    reasoning_effort : str, optional
        Passed as ``reasoning_effort`` to OpenAI reasoning models only (e.g.
        ``"low"``/``"medium"``/``"high"``); ignored for Anthropic and for
        non-reasoning OpenAI models.

    Returns
    -------
    dict
        Parsed JSON response body.
    """
    resolved_provider = resolve_provider(provider)
    if resolved_provider in {"jev", "openai_decisions", "laya", "jpt"}:
        raise LLMCallError(
            "Jev uses typed decisions rather than generative JSON. "
            "Call call_jev_decisions(), or use llm_classify(provider='jev')."
        )
    resolved_model = model or default_model_for(resolved_provider)

    last_error: Exception | None = None
    for attempt in range(1, max_retries + 1):
        try:
            if resolved_provider == "anthropic":
                text = _call_anthropic(system_prompt, user_prompt, resolved_model, max_tokens)
            elif resolved_provider == "openai":
                text = _call_openai(
                    system_prompt, user_prompt, resolved_model, max_tokens, reasoning_effort
                )
            elif resolved_provider == "mlx":
                from src.llm.local import call_mlx_json
                return call_mlx_json(system_prompt, user_prompt, resolved_model, max_tokens, reasoning_effort,
                                     thinking_budget if thinking_budget is not None else 1024)
            else:
                text = _call_google(system_prompt, user_prompt, resolved_model, max_tokens)
            return extract_json(text)
        except Exception as exc:  # noqa: BLE001 - broad by design, retried below
            last_error = exc
            logger.warning(
                "LLM call attempt %d/%d (provider=%s) failed: %s",
                attempt, max_retries, resolved_provider, exc,
            )

    raise LLMCallError(
        f"LLM call failed after {max_retries} attempts (provider={resolved_provider}): {last_error}"
    ) from last_error