File size: 17,595 Bytes
df2b222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Utility functions for the MCP Hub project."""

import json
import re
from typing import Dict, Any, List, Optional, Union
from openai import OpenAI, AsyncOpenAI
from .config import api_config, model_config
from .exceptions import APIError, ValidationError
from .logging_config import logger
import aiohttp
from huggingface_hub import InferenceClient


def create_nebius_client() -> OpenAI:
    """Create and return a Nebius OpenAI client."""
    return OpenAI(
        base_url=api_config.nebius_base_url,
        api_key=api_config.nebius_api_key,
    )

def create_async_nebius_client() -> AsyncOpenAI:
    """Create and return an async Nebius OpenAI client."""
    return AsyncOpenAI(
        base_url=api_config.nebius_base_url,
        api_key=api_config.nebius_api_key,
    )

def create_llm_client() -> Union[OpenAI, object]:
    """Create and return an LLM client based on the configured provider."""
    if api_config.llm_provider == "nebius":
        return create_nebius_client()
    elif api_config.llm_provider == "openai":
        return OpenAI(api_key=api_config.openai_api_key)
    elif api_config.llm_provider == "anthropic":
        try:
            import anthropic
            return anthropic.Anthropic(api_key=api_config.anthropic_api_key)
        except ImportError:
            raise APIError("Anthropic", "anthropic package not installed. Install with: pip install anthropic")
    elif api_config.llm_provider == "huggingface":
        # Try different HuggingFace client configurations for better compatibility
        try:
            # First try with hf-inference provider (most recent approach)
            return InferenceClient(
                provider="hf-inference",
                api_key=api_config.huggingface_api_key,
            )
        except Exception:
            # Fallback to token-based authentication
            return InferenceClient(
                token=api_config.huggingface_api_key,
            )
    else:
        raise APIError("Config", f"Unsupported LLM provider: {api_config.llm_provider}")

def create_async_llm_client() -> Union[AsyncOpenAI, object]:
    """Create and return an async LLM client based on the configured provider."""
    if api_config.llm_provider == "nebius":
        return create_async_nebius_client()
    elif api_config.llm_provider == "openai":
        return AsyncOpenAI(api_key=api_config.openai_api_key)
    elif api_config.llm_provider == "anthropic":
        try:
            import anthropic
            return anthropic.AsyncAnthropic(api_key=api_config.anthropic_api_key)
        except ImportError:
            raise APIError("Anthropic", "anthropic package not installed. Install with: pip install anthropic")
    elif api_config.llm_provider == "huggingface":
        # Try different HuggingFace client configurations for better compatibility
        try:
            # First try with hf-inference provider (most recent approach)
            return InferenceClient(
                provider="hf-inference",
                api_key=api_config.huggingface_api_key,
            )
        except Exception:
            # Fallback to token-based authentication
            return InferenceClient(
                token=api_config.huggingface_api_key,
            )
    else:
        raise APIError("Config", f"Unsupported LLM provider: {api_config.llm_provider}")

def validate_non_empty_string(value: str, field_name: str) -> None:
    """Validate that a string is not empty or None."""
    if not value or not value.strip():
        raise ValidationError(f"{field_name} cannot be empty.")

def extract_json_from_text(text: str) -> Dict[str, Any]:
    """Extract JSON object from text that may contain markdown fences."""
    # Remove markdown code fences if present
    if text.startswith("```"):
        parts = text.split("```")
        if len(parts) >= 3:
            text = parts[1].strip()
        else:
            text = text.strip("```").strip()
    
    # Find JSON object boundaries
    start_idx = text.find("{")
    end_idx = text.rfind("}")
    
    if start_idx == -1 or end_idx == -1 or end_idx < start_idx:
        raise ValidationError("Failed to locate JSON object in text.")
    
    json_candidate = text[start_idx:end_idx + 1]
    
    try:
        return json.loads(json_candidate)
    except json.JSONDecodeError as e:
        raise ValidationError(f"Failed to parse JSON: {str(e)}")

def extract_urls_from_text(text: str) -> List[str]:
    """Extract URLs from text using regex."""
    url_pattern = r"(https?://[^\s]+)"
    return re.findall(url_pattern, text)

def make_nebius_completion(

    model: str,

    messages: List[Dict[str, str]],

    temperature: float = 0.6,

    response_format: Optional[Dict[str, Any]] = None

) -> str:
    """Make a completion request to Nebius and return the content."""
    client = create_nebius_client()
    
    try:
        kwargs = {
            "model": model,
            "messages": messages,
            "temperature": temperature,
        }
        
        if response_format:
            kwargs["response_format"] = response_format
        
        completion = client.chat.completions.create(**kwargs)
        return completion.choices[0].message.content.strip()
    except Exception as e:
        raise APIError("Nebius", str(e))

async def make_async_nebius_completion(

    model: str,

    messages: List[Dict[str, Any]],

    temperature: float = 0.0,

    response_format: Optional[Dict[str, Any]] = None,

) -> str:
    """Make an async completion request to Nebius API."""
    try:
        client = create_async_nebius_client()
        
        kwargs = {
            "model": model,
            "messages": messages,
            "temperature": temperature
        }
        
        if response_format:
            kwargs["response_format"] = response_format
        
        response = await client.chat.completions.create(**kwargs)
        
        if not response.choices:
            raise APIError("Nebius", "No completion choices returned")
        
        content = response.choices[0].message.content
        if content is None:
            raise APIError("Nebius", "Empty response content")
        
        return content.strip()
        
    except Exception as e:
        if isinstance(e, APIError):
            raise
        raise APIError("Nebius", f"API call failed: {str(e)}")

def make_llm_completion(

    model: str,

    messages: List[Dict[str, str]], 

    temperature: float = 0.6,

    response_format: Optional[Dict[str, Any]] = None

) -> str:
    """Make a completion request using the configured LLM provider."""
    provider = api_config.llm_provider
    
    try:
        if provider == "nebius":
            return make_nebius_completion(model, messages, temperature, response_format)
        
        elif provider == "openai":
            client = create_llm_client()
            kwargs = {
                "model": model,
                "messages": messages,
                "temperature": temperature,
            }
            # OpenAI only supports simple response_format, not the extended Nebius format
            if response_format and response_format.get("type") == "json_object":
                kwargs["response_format"] = {"type": "json_object"}
            completion = client.chat.completions.create(**kwargs)
            return completion.choices[0].message.content.strip()
        
        elif provider == "anthropic":
            client = create_llm_client()
            # Convert OpenAI format to Anthropic format
            anthropic_messages = []
            system_message = None
            
            for msg in messages:
                if msg["role"] == "system":
                    system_message = msg["content"]
                else:
                    anthropic_messages.append({
                        "role": msg["role"],
                        "content": msg["content"]
                    })
            
            kwargs = {
                "model": model,
                "messages": anthropic_messages,
                "temperature": temperature,
                "max_tokens": 1000,
            }
            if system_message:
                kwargs["system"] = system_message
            
            response = client.messages.create(**kwargs)
            return response.content[0].text.strip()
        
        elif provider == "huggingface":
            # Try HuggingFace with fallback to Nebius
            hf_error = None
            try:
                client = create_llm_client()
                
                # Try multiple HuggingFace API approaches
                
                # Method 1: Try chat.completions.create (OpenAI-compatible)
                try:
                    response = client.chat.completions.create(
                        model=model,
                        messages=messages,
                        temperature=temperature,
                        max_tokens=1000,
                    )
                    
                    # Extract the response content
                    if hasattr(response, 'choices') and response.choices:
                        return response.choices[0].message.content.strip()
                    else:
                        return str(response).strip()
                        
                except Exception as e1:
                    hf_error = e1
                    
                    # Method 2: Try chat_completion method (HuggingFace native)
                    try:
                        response = client.chat_completion(
                            messages=messages,
                            model=model,
                            temperature=temperature,
                            max_tokens=1000,
                        )
                        
                        # Handle different response formats
                        if hasattr(response, 'generated_text'):
                            return response.generated_text.strip()
                        elif isinstance(response, dict) and 'generated_text' in response:
                            return response['generated_text'].strip()
                        elif isinstance(response, list) and len(response) > 0:
                            if isinstance(response[0], dict) and 'generated_text' in response[0]:
                                return response[0]['generated_text'].strip()
                        
                        return str(response).strip()
                        
                    except Exception as e2:
                        # Both HuggingFace methods failed
                        hf_error = f"Method 1: {str(e1)}. Method 2: {str(e2)}"
                        raise APIError("HuggingFace", f"All HuggingFace methods failed. {hf_error}")
                
            except Exception as e:
                # HuggingFace failed, try fallback to Nebius
                if hf_error is None:
                    hf_error = str(e)
                logger.warning(f"HuggingFace API failed: {hf_error}, falling back to Nebius")
                
                try:
                    # Use Nebius model appropriate for the task
                    nebius_model = model_config.get_model_for_provider("question_enhancer", "nebius")
                    return make_nebius_completion(nebius_model, messages, temperature, response_format)
                except Exception as nebius_error:
                    raise APIError("HuggingFace", f"HuggingFace failed: {hf_error}. Nebius fallback also failed: {str(nebius_error)}")
        
        else:
            raise APIError("Config", f"Unsupported LLM provider: {provider}")
        
    except Exception as e:
        raise APIError(provider.title(), f"Completion failed: {str(e)}")


async def make_async_llm_completion(

    model: str,

    messages: List[Dict[str, Any]],

    temperature: float = 0.0,

    response_format: Optional[Dict[str, Any]] = None,

) -> str:
    """Make an async completion request using the configured LLM provider."""
    provider = api_config.llm_provider

    try:
        if provider == "nebius":
            return await make_async_nebius_completion(model, messages, temperature, response_format)

        elif provider == "openai":
            client = create_async_llm_client()
            kwargs = {
                "model": model,
                "messages": messages,
                "temperature": temperature
            }
            if response_format and response_format.get("type") == "json_object":
                kwargs["response_format"] = {"type": "json_object"}

            response = await client.chat.completions.create(**kwargs)

            if not response.choices:
                raise APIError("OpenAI", "No completion choices returned")

            content = response.choices[0].message.content
            if content is None:
                raise APIError("OpenAI", "Empty response content")

            return content.strip()

        elif provider == "anthropic":
            client = create_async_llm_client()
            anthropic_messages = []
            system_message = None

            for msg in messages:
                if msg["role"] == "system":
                    system_message = msg["content"]
                else:
                    anthropic_messages.append({
                        "role": msg["role"],
                        "content": msg["content"]
                    })

            kwargs = {
                "model": model,
                "messages": anthropic_messages,
                "temperature": temperature,
                "max_tokens": 1000,
            }
            if system_message:
                kwargs["system"] = system_message

            response = await client.messages.create(**kwargs)
            return response.content[0].text.strip()

        elif provider == "huggingface":
            # HuggingFace doesn't support async, fallback to Nebius
            logger.warning("HuggingFace does not support async operations, falling back to Nebius")
            
            try:
                # Use Nebius model appropriate for the task
                nebius_model = model_config.get_model_for_provider("question_enhancer", "nebius")
                return await make_async_nebius_completion(nebius_model, messages, temperature, response_format)
            except Exception as nebius_error:
                raise APIError("HuggingFace", f"HuggingFace async not supported. Nebius fallback failed: {str(nebius_error)}")

        else:
            raise APIError("Config", f"Unsupported LLM provider: {provider}")

    except Exception as e:
        raise APIError(provider.title(), f"Async completion failed: {str(e)}")

async def async_tavily_search(query: str, max_results: int = 3) -> Dict[str, Any]:
    """Perform async web search using Tavily API."""
    try:
        async with aiohttp.ClientSession() as session:
            url = "https://api.tavily.com/search"
            headers = {
                "Content-Type": "application/json"
            }
            data = {
                "api_key": api_config.tavily_api_key,
                "query": query,
                "search_depth": "basic",
                "max_results": max_results,
                "include_answer": True
            }
            
            async with session.post(url, headers=headers, json=data) as response:
                if response.status != 200:
                    raise APIError("Tavily", f"HTTP {response.status}: {await response.text()}")
                
                result = await response.json()
                return {
                    "query": result.get("query", query),
                    "tavily_answer": result.get("answer"),
                    "results": result.get("results", []),
                    "data_source": "Tavily Search API",
                }
                
    except aiohttp.ClientError as e:
        raise APIError("Tavily", f"HTTP request failed: {str(e)}")
    except Exception as e:
        if isinstance(e, APIError):
            raise
        raise APIError("Tavily", f"Search failed: {str(e)}")

def format_search_results(results: List[Dict[str, Any]]) -> str:
    """Format search results into a readable string."""
    if not results:
        return "No search results found."
    
    snippets = []
    for idx, item in enumerate(results, 1):
        title = item.get("title", "No Title")
        url = item.get("url", "")
        content = item.get("content", "")
        
        snippet = f"Result {idx}:\nTitle: {title}\nURL: {url}\nSnippet: {content}\n"
        snippets.append(snippet)
    
    return "\n".join(snippets).strip()

def create_apa_citation(url: str, year: str = None) -> str:
    """Create a simple APA-style citation from a URL."""
    if not year:
        year = api_config.current_year
    
    try:
        domain = url.split("/")[2]
        title = domain.replace("www.", "").split(".")[0].capitalize()
        return f"{title}. ({year}). Retrieved from {url}"
    except (IndexError, AttributeError):
        return f"Unknown Source. ({year}). Retrieved from {url}"