File size: 13,844 Bytes
19de729
59ebe66
bfcc872
 
 
057c21e
bfcc872
 
59ebe66
057c21e
 
 
 
19de729
 
bfcc872
 
 
 
 
 
59ebe66
 
 
19de729
 
 
bfcc872
 
19de729
 
2ae7490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfcc872
 
 
 
 
2ae7490
 
bfcc872
2ae7490
 
bfcc872
 
 
 
 
 
 
 
 
 
 
 
 
 
2ae7490
 
bfcc872
 
 
2ae7490
 
 
 
 
 
 
bfcc872
 
 
 
 
 
 
 
 
 
 
19de729
 
bfcc872
 
 
057c21e
 
 
 
 
 
 
 
 
 
 
 
cf9b3dc
057c21e
 
 
 
cf9b3dc
057c21e
 
 
 
 
cf9b3dc
 
 
 
 
057c21e
 
 
bfcc872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19de729
 
 
 
 
 
 
 
ad70c89
057c21e
 
 
19de729
bfcc872
cf9b3dc
bfcc872
 
 
 
 
 
ad70c89
057c21e
 
 
 
 
 
 
 
 
 
bfcc872
ad70c89
bfcc872
 
 
 
 
 
 
 
 
 
 
 
 
cf9b3dc
 
 
 
 
 
 
984ffe4
cf9b3dc
 
 
eaadb58
 
 
cf9b3dc
 
 
 
 
 
bfcc872
cf9b3dc
bfcc872
 
 
 
 
 
 
 
 
 
 
 
ad70c89
 
 
 
 
 
 
bfcc872
ad70c89
 
 
bfcc872
ad70c89
bfcc872
 
19de729
ad70c89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19de729
 
 
 
 
 
 
 
bfcc872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19de729
 
 
 
cf9b3dc
19de729
 
 
 
 
 
 
 
 
 
 
bfcc872
 
 
19de729
 
bfcc872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# src/analyzer/llm_client.py
from __future__ import annotations
import logging
import os
import time
from typing import Any, Dict, List, Optional, Callable, Literal

from .utils.errors import LLMError, ConfigError

ModelType = Literal["router", "translator", "analyzer"]
VerbosityLevel = Literal["low", "medium", "high"]
ReasoningEffort = Literal["minimal", "medium", "high"]

try:
    from openai import OpenAI
    import httpx
except ImportError:
    OpenAI = None
    httpx = None

logger = logging.getLogger(__name__)


class LLMClient:
    """
    Lightweight wrapper around OpenAI Chat Completions with:
      - per-call max_tokens
      - retry with exponential backoff
      - proper error handling and timeouts
    """

    def __init__(self, cfg: Any = None):
        """
        Initialize LLM client with config.

        Args:
            cfg: Configuration object/dict (deprecated - uses get_settings() if None)

        Raises:
            ConfigError: If provider is not OpenAI or client initialization fails
        """
        # Use new settings if no config provided
        if cfg is None:
            from .config import get_settings
            settings = get_settings()
            self.provider = settings.LLM_PROVIDER
            self.model = settings.LLM_MODEL_QA
            self.disable_llm = settings.DISABLE_LLM
            self.model_router = settings.LLM_MODEL_ROUTER
            self.model_translator = settings.LLM_MODEL_TRANSLATE
            self.model_analyzer = settings.LLM_MODEL_QA  # Use QA model for analysis
            api_key = settings.OPENAI_API_KEY
            base_url = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
            timeout_s = settings.TIMEOUT_S
        else:
            # Legacy config object/dict support
            self.provider = self._get_cfg(cfg, "provider", "openai")
            self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
            self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
            self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
            self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
            self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
            api_key = self._get_cfg(cfg, "api_key") or os.getenv("OPENAI_API_KEY")
            base_url = self._get_cfg(cfg, "base_url") or os.getenv(
                "OPENAI_API_BASE",
                "https://api.openai.com/v1"
            )
            timeout_s = self._get_cfg(cfg, "timeout_s", 30.0)

        self.max_retries = int(os.getenv("LLM_MAX_RETRIES", "3"))
        self.retry_delay = float(os.getenv("LLM_RETRY_DELAY", "1.0"))
        self.client = None

        # CRITICAL: Only OpenAI is supported - fail fast
        if self.provider != "openai":
            raise ConfigError(
                f"Unsupported LLM_PROVIDER: {self.provider}. "
                f"Only 'openai' is currently supported."
            )

        # Initialize client (if not disabled)
        if self.disable_llm:
            logger.info("LLM disabled via config")
            return

        if OpenAI is None:
            raise ConfigError(
                "openai package not installed. Install with: pip install openai"
            )

        if not api_key:
            raise ConfigError(
                "OPENAI_API_KEY is required when LLM_PROVIDER=openai. "
                "Set the OPENAI_API_KEY environment variable."
            )

        try:
            # Create client with timeout
            timeout = httpx.Timeout(timeout_s, connect=10.0) if httpx else None
            self.client = OpenAI(
                api_key=api_key,
                base_url=base_url,
                timeout=timeout
            )
            logger.info("LLMClient initialized: %s/%s", self.provider, self.model)
        except Exception as e:
            raise ConfigError(f"Failed to initialize OpenAI client: {e}") from e

    def _get_cfg(self, cfg: Any, key: str, default: Any = None) -> Any:
        """Extract config value (works with both dict and object)."""
        if hasattr(cfg, key):
            return getattr(cfg, key) or default
        if isinstance(cfg, dict):
            return cfg.get(key, default)
        return default

    def is_ready(self) -> bool:
        """Check if LLM client is ready to use."""
        return self.client is not None and not self.disable_llm

    def _get_model_for_type(self, model_type: Optional[ModelType] = None) -> str:
        """Get the appropriate model for the given type."""
        if model_type == "router":
            return self.model_router
        elif model_type == "translator":
            return self.model_translator
        elif model_type == "analyzer":
            return self.model_analyzer
        else:
            return self.model

    @staticmethod
    def get_recommended_params(task_type: str) -> Dict[str, Any]:
        """
        Get recommended verbosity and reasoning_effort for common tasks.

        Args:
            task_type: One of "translation", "analysis", "routing", "summary", "comparison"

        Returns:
            Dict with verbosity and reasoning_effort settings
        """
        presets = {
            "translation": {"verbosity": "medium", "reasoning_effort": "minimal"},
            "analysis": {"verbosity": "high", "reasoning_effort": "high"},
            "routing": {"verbosity": "low", "reasoning_effort": "minimal"},
            "summary": {"verbosity": "medium", "reasoning_effort": "medium"},
            "comparison": {"verbosity": "high", "reasoning_effort": "high"}
        }
        return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})

    def _retry_with_backoff(
        self,
        fn: Callable[[], Any],
        *,
        max_attempts: int = 3,
        initial_delay: float = 1.0
    ) -> Any:
        """
        Retry function with exponential backoff.

        Retries on transient errors only (timeouts, rate limits).
        Re-raises immediately on permanent errors (auth, invalid model).
        """
        last_error = None
        delay = initial_delay

        for attempt in range(1, max_attempts + 1):
            try:
                return fn()

            except Exception as e:
                last_error = e
                error_str = str(e).lower()

                # Don't retry on permanent errors
                if any(x in error_str for x in [
                    'api key', 'auth', 'forbidden', 'unauthorized',
                    'invalid model', 'model not found'
                ]):
                    raise LLMError(f"Permanent LLM error: {e}") from e

                # Don't retry on programming errors
                if isinstance(e, (ValueError, TypeError, KeyError)):
                    raise LLMError(f"LLM call failed (bad input): {e}") from e

                # Retry on transient errors
                if attempt < max_attempts:
                    logger.warning(
                        "LLM call failed (attempt %d/%d): %s. Retrying in %.1fs...",
                        attempt, max_attempts, e, delay
                    )
                    time.sleep(delay)
                    delay *= 2  # Exponential backoff
                    continue

        # All retries exhausted
        raise LLMError(
            f"LLM call failed after {max_attempts} attempts: {last_error}"
        ) from last_error

    def chat(
        self,
        messages: List[Dict[str, str]],
        *,
        max_tokens: int = 900,
        temperature: float = 0.2,
        top_p: float = 1.0,
        stream: bool = False,
        model_type: Optional[ModelType] = None,
        verbosity: Optional[VerbosityLevel] = None,
        reasoning_effort: Optional[ReasoningEffort] = None,
    ) -> str:
        """
        Single chat completion call with optional streaming and GPT-5 features.

        Args:
            messages: List of message dicts with 'role' and 'content'
            max_tokens: Maximum tokens to generate
            temperature: Sampling temperature (0-2)
            top_p: Nucleus sampling parameter
            stream: If True, returns generator yielding tokens (else full response)
            model_type: Type of model to use ('router', 'translator', or 'analyzer')
            verbosity: Response length control (GPT-5 feature)
                - 'low': Brief, concise responses
                - 'medium': Standard length responses
                - 'high': Detailed, comprehensive responses
            reasoning_effort: Thinking time control (GPT-5 feature)
                - 'minimal': Quick, straightforward responses
                - 'medium': Moderate analysis and reasoning
                - 'high': Deep analysis and careful reasoning

        Returns:
            Generated text response (or generator if stream=True)

        Raises:
            LLMError: On API failures or invalid responses
            ConfigError: If client not initialized
        """
        if not self.is_ready():
            if self.disable_llm:
                return self._fallback_response(messages)
            raise LLMError(
                "LLM client not initialized. Check API key and configuration."
            )

        def _make_call():
            # Select model based on model_type
            model = self._get_model_for_type(model_type)

            # Build API parameters
            api_params = {
                "model": model,
                "messages": messages,
                "max_completion_tokens": max_tokens,  # GPT-5 uses max_completion_tokens
                "stream": stream,
            }

            # GPT-5 only supports temperature=1.0 (default), so don't set it
            # For other models, we could add temperature back if needed

            # Add GPT-5 specific parameters if provided
            if verbosity is not None:
                api_params["verbosity"] = verbosity
            if reasoning_effort is not None:
                api_params["reasoning_effort"] = reasoning_effort

            try:
                resp = self.client.chat.completions.create(**api_params)
            except Exception as e:
                # Handle httpx exceptions if available
                if httpx and isinstance(e, httpx.TimeoutException):
                    raise LLMError(f"LLM request timed out") from e
                elif httpx and isinstance(e, httpx.HTTPStatusError):
                    status = e.response.status_code
                    raise LLMError(
                        f"LLM API error (HTTP {status}): {e.response.text[:200]}"
                    ) from e
                else:
                    raise LLMError(f"LLM API call failed: {e}") from e

            if stream:
                # Return generator for streaming
                return self._stream_response(resp)
            else:
                # Validate response
                if not resp.choices:
                    raise LLMError("LLM returned no choices")

                content = resp.choices[0].message.content
                if not content:
                    raise LLMError("LLM returned empty response")

                return content.strip()

        return self._retry_with_backoff(_make_call, max_attempts=self.max_retries)

    def _stream_response(self, stream_resp):
        """
        Handle streaming response from OpenAI API.
        Yields chunks of text as they arrive.
        """
        try:
            full_text = ""
            for chunk in stream_resp:
                if chunk.choices and chunk.choices[0].delta.content:
                    token = chunk.choices[0].delta.content
                    full_text += token
                    yield token
        except Exception as e:
            raise LLMError(f"Stream error: {e}") from e

    def summarize(
        self,
        user_text: str,
        *,
        system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
        max_tokens: int = 900,
        temperature: float = 0.2,
    ) -> str:
        """
        Convenience: single-turn chat.

        Args:
            user_text: User message content
            system_text: System prompt
            max_tokens: Maximum tokens
            temperature: Sampling temperature

        Returns:
            Generated summary

        Raises:
            LLMError: On API failures
        """
        messages = [
            {"role": "system", "content": system_text},
            {"role": "user", "content": user_text},
        ]
        return self.chat(messages, max_tokens=max_tokens, temperature=temperature)

    def summarize_long(
        self,
        user_text: str,
        *,
        system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
        max_tokens: int = 1300,
        temperature: float = 0.2,
        hard_clip_chars: int = 60_000,
    ) -> str:
        """
        Larger token budget with optional hard clip on input.

        Prevents 400 errors from overly long prompts.
        """
        if len(user_text) > hard_clip_chars:
            logger.warning(
                "Input text truncated from %d to %d chars",
                len(user_text), hard_clip_chars
            )
            user_text = user_text[:hard_clip_chars] + "\n\n[...truncated...]"

        return self.summarize(
            user_text,
            system_text=system_text,
            max_tokens=max_tokens,
            temperature=temperature
        )

    def _fallback_response(self, messages: List[Dict]) -> str:
        """Return safe fallback when LLM is disabled."""
        user_msg = next(
            (m['content'] for m in reversed(messages) if m['role'] == 'user'),
            ""
        )
        return f"[LLM disabled] Your query: {user_msg[:100]}..."