yugbirla commited on
Commit
148db91
·
1 Parent(s): f4e3263

Harden Hugging Face hosted LLM provider

Browse files
app/core/config.py CHANGED
@@ -119,6 +119,11 @@ class Settings:
119
  )
120
  HF_TIMEOUT_SECONDS: int = get_int_env("HF_TIMEOUT_SECONDS", 60)
121
 
 
 
 
 
 
122
  MAX_GENERATION_TOKENS: int = get_int_env("MAX_GENERATION_TOKENS", 220)
123
  LOCAL_LLM_MAX_INPUT_TOKENS: int = get_int_env("LOCAL_LLM_MAX_INPUT_TOKENS", 1024)
124
 
 
119
  )
120
  HF_TIMEOUT_SECONDS: int = get_int_env("HF_TIMEOUT_SECONDS", 60)
121
 
122
+ # auto = try best route based on model name
123
+ # chat = force router chat-completions API
124
+ # inference = force HF inference model endpoint
125
+ HF_API_MODE: str = os.getenv("HF_API_MODE", "auto")
126
+
127
  MAX_GENERATION_TOKENS: int = get_int_env("MAX_GENERATION_TOKENS", 220)
128
  LOCAL_LLM_MAX_INPUT_TOKENS: int = get_int_env("LOCAL_LLM_MAX_INPUT_TOKENS", 1024)
129
 
app/generation/providers/huggingface_provider.py CHANGED
@@ -1,4 +1,5 @@
1
- from typing import Dict, Any
 
2
  import requests
3
  import re
4
 
@@ -9,53 +10,229 @@ from app.generation.providers.base_provider import BaseLLMProvider
9
  class HuggingFaceLLMProvider(BaseLLMProvider):
10
  provider_name = "huggingface"
11
 
 
 
 
 
 
12
  def generate(self, prompt: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  if not settings.HF_API_TOKEN:
 
14
  return ""
15
 
16
- try:
17
- url = get_hf_inference_url()
18
 
19
- headers = {
20
- "Authorization": f"Bearer {settings.HF_API_TOKEN}",
21
- "Content-Type": "application/json"
22
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- payload = {
25
- "inputs": prompt,
26
- "parameters": {
27
- "max_new_tokens": settings.MAX_GENERATION_TOKENS,
28
- "do_sample": False,
29
- "return_full_text": False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
- }
 
 
 
32
 
33
- response = requests.post(
34
- url=url,
35
- headers=headers,
36
- json=payload,
37
- timeout=settings.HF_TIMEOUT_SECONDS
38
- )
39
 
40
- if response.status_code != 200:
41
- return ""
42
 
43
- data = response.json()
44
- answer = parse_huggingface_response(data)
45
 
46
- return clean_hosted_output(answer)
 
 
47
 
48
- except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  return ""
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def status(self) -> Dict[str, Any]:
52
  return {
53
  "provider": self.provider_name,
54
  "enabled": bool(settings.HF_API_TOKEN),
55
  "model_name": settings.HF_INFERENCE_MODEL,
 
56
  "custom_url_set": bool(settings.HF_INFERENCE_URL),
57
  "timeout_seconds": settings.HF_TIMEOUT_SECONDS,
58
- "token_present": bool(settings.HF_API_TOKEN)
 
 
 
 
 
 
 
 
59
  }
60
 
61
  def load_test(self) -> Dict[str, Any]:
@@ -66,56 +243,128 @@ class HuggingFaceLLMProvider(BaseLLMProvider):
66
  "message": "HF_API_TOKEN is missing."
67
  }
68
 
69
- try:
70
- test_prompt = "Answer briefly: What is RAG?"
 
 
 
71
 
72
- answer = self.generate(test_prompt)
73
 
74
- return {
75
- "loaded": bool(answer),
76
- "provider": self.provider_name,
77
- "model_name": settings.HF_INFERENCE_MODEL,
78
- "answer_preview": answer[:200],
79
- "message": "Hugging Face provider call completed."
80
- }
 
 
 
 
 
 
 
 
81
 
82
- except Exception as error:
83
- return {
84
- "loaded": False,
85
- "provider": self.provider_name,
86
- "model_name": settings.HF_INFERENCE_MODEL,
87
- "error": str(error)
88
- }
89
 
 
 
 
 
 
 
 
 
 
90
 
91
- def get_hf_inference_url() -> str:
92
- if settings.HF_INFERENCE_URL:
93
- return settings.HF_INFERENCE_URL
94
 
95
- return f"https://api-inference.huggingface.co/models/{settings.HF_INFERENCE_MODEL}"
 
96
 
 
97
 
98
- def parse_huggingface_response(data) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  if isinstance(data, list) and data:
100
  first_item = data[0]
101
 
102
  if isinstance(first_item, dict):
103
- if "generated_text" in first_item:
104
- return str(first_item["generated_text"])
 
 
 
 
 
105
 
106
- if "summary_text" in first_item:
107
- return str(first_item["summary_text"])
108
 
109
  if isinstance(data, dict):
110
- if "generated_text" in data:
111
- return str(data["generated_text"])
112
-
113
- if "summary_text" in data:
114
- return str(data["summary_text"])
 
 
115
 
116
  if "error" in data:
117
  return ""
118
 
 
 
 
119
  return ""
120
 
121
 
@@ -125,6 +374,17 @@ def clean_hosted_output(answer: str) -> str:
125
 
126
  cleaned = answer.strip()
127
 
 
 
 
 
 
 
 
 
 
 
 
128
  cleaned = re.sub(r"\s+", " ", cleaned)
129
  cleaned = cleaned.replace(" .", ".")
130
  cleaned = cleaned.replace(" ,", ",")
 
1
+ from typing import Dict, Any, Optional
2
+ import time
3
  import requests
4
  import re
5
 
 
10
  class HuggingFaceLLMProvider(BaseLLMProvider):
11
  provider_name = "huggingface"
12
 
13
+ def __init__(self):
14
+ self.last_error: Optional[str] = None
15
+ self.last_status_code: Optional[int] = None
16
+ self.last_api_mode: Optional[str] = None
17
+
18
  def generate(self, prompt: str) -> str:
19
+ """
20
+ Generate answer using Hugging Face hosted inference.
21
+
22
+ Strategy:
23
+ 1. If model looks like chat/instruct provider model, try router chat API.
24
+ 2. Otherwise try HF inference model endpoint.
25
+ 3. If one fails, try the other.
26
+ 4. If all fail, return empty string so answer_service fallback is used.
27
+ """
28
+
29
+ self.last_error = None
30
+ self.last_status_code = None
31
+ self.last_api_mode = None
32
+
33
  if not settings.HF_API_TOKEN:
34
+ self.last_error = "HF_API_TOKEN is missing."
35
  return ""
36
 
37
+ api_mode = get_hf_api_mode()
 
38
 
39
+ if api_mode == "chat":
40
+ answer = self.call_chat_completion_api(prompt)
41
+
42
+ if answer:
43
+ return clean_hosted_output(answer)
44
+
45
+ answer = self.call_hf_inference_model_api(prompt)
46
+
47
+ if answer:
48
+ return clean_hosted_output(answer)
49
+
50
+ return ""
51
+
52
+ if api_mode == "inference":
53
+ answer = self.call_hf_inference_model_api(prompt)
54
+
55
+ if answer:
56
+ return clean_hosted_output(answer)
57
+
58
+ answer = self.call_chat_completion_api(prompt)
59
+
60
+ if answer:
61
+ return clean_hosted_output(answer)
62
+
63
+ return ""
64
+
65
+ # auto mode
66
+ if should_try_chat_first(settings.HF_INFERENCE_MODEL):
67
+ first_answer = self.call_chat_completion_api(prompt)
68
+
69
+ if first_answer:
70
+ return clean_hosted_output(first_answer)
71
+
72
+ second_answer = self.call_hf_inference_model_api(prompt)
73
+
74
+ if second_answer:
75
+ return clean_hosted_output(second_answer)
76
+
77
+ return ""
78
+
79
+ first_answer = self.call_hf_inference_model_api(prompt)
80
+
81
+ if first_answer:
82
+ return clean_hosted_output(first_answer)
83
+
84
+ second_answer = self.call_chat_completion_api(prompt)
85
 
86
+ if second_answer:
87
+ return clean_hosted_output(second_answer)
88
+
89
+ return ""
90
+
91
+ def call_chat_completion_api(self, prompt: str) -> str:
92
+ """
93
+ Uses Hugging Face router OpenAI-compatible chat-completion endpoint.
94
+
95
+ Best for provider-backed chat/instruct models.
96
+ """
97
+
98
+ self.last_api_mode = "chat"
99
+
100
+ url = "https://router.huggingface.co/v1/chat/completions"
101
+
102
+ headers = {
103
+ "Authorization": f"Bearer {settings.HF_API_TOKEN}",
104
+ "Content-Type": "application/json"
105
+ }
106
+
107
+ payload = {
108
+ "model": settings.HF_INFERENCE_MODEL,
109
+ "messages": [
110
+ {
111
+ "role": "system",
112
+ "content": (
113
+ "You are a careful research assistant. "
114
+ "Answer only from the supplied evidence and preserve citations like [S1]."
115
+ )
116
+ },
117
+ {
118
+ "role": "user",
119
+ "content": prompt
120
  }
121
+ ],
122
+ "max_tokens": settings.MAX_GENERATION_TOKENS,
123
+ "temperature": 0
124
+ }
125
 
126
+ data = self.post_with_retries(url=url, headers=headers, payload=payload)
 
 
 
 
 
127
 
128
+ if not data:
129
+ return ""
130
 
131
+ return parse_chat_completion_response(data)
 
132
 
133
+ def call_hf_inference_model_api(self, prompt: str) -> str:
134
+ """
135
+ Uses Hugging Face HF Inference model endpoint.
136
 
137
+ Better for classic text/text2text models like google/flan-t5-base.
138
+ """
139
+
140
+ self.last_api_mode = "inference"
141
+
142
+ model_name = settings.HF_INFERENCE_MODEL
143
+
144
+ if settings.HF_INFERENCE_URL:
145
+ url = settings.HF_INFERENCE_URL
146
+ else:
147
+ url = f"https://router.huggingface.co/hf-inference/models/{model_name}"
148
+
149
+ headers = {
150
+ "Authorization": f"Bearer {settings.HF_API_TOKEN}",
151
+ "Content-Type": "application/json"
152
+ }
153
+
154
+ payload = {
155
+ "inputs": prompt,
156
+ "parameters": {
157
+ "max_new_tokens": settings.MAX_GENERATION_TOKENS,
158
+ "do_sample": False,
159
+ "return_full_text": False
160
+ },
161
+ "options": {
162
+ "wait_for_model": True
163
+ }
164
+ }
165
+
166
+ data = self.post_with_retries(url=url, headers=headers, payload=payload)
167
+
168
+ if not data:
169
  return ""
170
 
171
+ return parse_huggingface_inference_response(data)
172
+
173
+ def post_with_retries(
174
+ self,
175
+ url: str,
176
+ headers: Dict[str, str],
177
+ payload: Dict[str, Any],
178
+ max_retries: int = 3
179
+ ) -> Optional[Any]:
180
+
181
+ retryable_status_codes = {429, 500, 502, 503, 504}
182
+
183
+ for attempt in range(1, max_retries + 1):
184
+ try:
185
+ response = requests.post(
186
+ url=url,
187
+ headers=headers,
188
+ json=payload,
189
+ timeout=settings.HF_TIMEOUT_SECONDS
190
+ )
191
+
192
+ self.last_status_code = response.status_code
193
+
194
+ if response.status_code == 200:
195
+ return response.json()
196
+
197
+ error_text = response.text[:500]
198
+ self.last_error = f"HTTP {response.status_code}: {error_text}"
199
+
200
+ if response.status_code not in retryable_status_codes:
201
+ return None
202
+
203
+ time.sleep(attempt * 2)
204
+
205
+ except requests.Timeout:
206
+ self.last_error = "Hugging Face request timed out."
207
+ time.sleep(attempt * 2)
208
+
209
+ except requests.RequestException as error:
210
+ self.last_error = f"Request error: {str(error)}"
211
+ time.sleep(attempt * 2)
212
+
213
+ except Exception as error:
214
+ self.last_error = f"Unexpected error: {str(error)}"
215
+ return None
216
+
217
+ return None
218
+
219
  def status(self) -> Dict[str, Any]:
220
  return {
221
  "provider": self.provider_name,
222
  "enabled": bool(settings.HF_API_TOKEN),
223
  "model_name": settings.HF_INFERENCE_MODEL,
224
+ "api_mode": get_hf_api_mode(),
225
  "custom_url_set": bool(settings.HF_INFERENCE_URL),
226
  "timeout_seconds": settings.HF_TIMEOUT_SECONDS,
227
+ "token_present": bool(settings.HF_API_TOKEN),
228
+ "last_api_mode": self.last_api_mode,
229
+ "last_status_code": self.last_status_code,
230
+ "last_error": self.last_error,
231
+ "notes": {
232
+ "chat_mode": "Uses https://router.huggingface.co/v1/chat/completions",
233
+ "inference_mode": "Uses https://router.huggingface.co/hf-inference/models/{model}",
234
+ "fallback": "If hosted LLM fails, answer_service uses evidence-based fallback."
235
+ }
236
  }
237
 
238
  def load_test(self) -> Dict[str, Any]:
 
243
  "message": "HF_API_TOKEN is missing."
244
  }
245
 
246
+ test_prompt = (
247
+ "Answer with one short sentence and include [S1]. "
248
+ "Evidence: S1: RAG stands for Retrieval-Augmented Generation. [S1] "
249
+ "Question: What is RAG?"
250
+ )
251
 
252
+ answer = self.generate(test_prompt)
253
 
254
+ return {
255
+ "loaded": bool(answer),
256
+ "provider": self.provider_name,
257
+ "model_name": settings.HF_INFERENCE_MODEL,
258
+ "api_mode": get_hf_api_mode(),
259
+ "last_api_mode": self.last_api_mode,
260
+ "last_status_code": self.last_status_code,
261
+ "last_error": self.last_error,
262
+ "answer_preview": answer[:300],
263
+ "message": (
264
+ "Hosted Hugging Face provider test completed."
265
+ if answer
266
+ else "Hosted Hugging Face provider returned no usable answer. Fallback will still work."
267
+ )
268
+ }
269
 
 
 
 
 
 
 
 
270
 
271
+ def get_hf_api_mode() -> str:
272
+ """
273
+ Supported:
274
+ - auto
275
+ - chat
276
+ - inference
277
+
278
+ Default is auto.
279
+ """
280
 
281
+ mode = getattr(settings, "HF_API_MODE", "auto")
282
+ mode = str(mode).lower().strip()
 
283
 
284
+ if mode in ["chat", "inference", "auto"]:
285
+ return mode
286
 
287
+ return "auto"
288
 
289
+
290
+ def should_try_chat_first(model_name: str) -> bool:
291
+ model_lower = model_name.lower()
292
+
293
+ chat_markers = [
294
+ "instruct",
295
+ "chat",
296
+ "qwen",
297
+ "llama",
298
+ "mistral",
299
+ "gemma",
300
+ "phi",
301
+ ":"
302
+ ]
303
+
304
+ return any(marker in model_lower for marker in chat_markers)
305
+
306
+
307
+ def parse_chat_completion_response(data: Any) -> str:
308
+ if not isinstance(data, dict):
309
+ return ""
310
+
311
+ choices = data.get("choices", [])
312
+
313
+ if not choices:
314
+ return ""
315
+
316
+ first_choice = choices[0]
317
+
318
+ if not isinstance(first_choice, dict):
319
+ return ""
320
+
321
+ message = first_choice.get("message", {})
322
+
323
+ if isinstance(message, dict):
324
+ content = message.get("content", "")
325
+
326
+ if isinstance(content, str):
327
+ return content
328
+
329
+ text = first_choice.get("text", "")
330
+
331
+ if isinstance(text, str):
332
+ return text
333
+
334
+ return ""
335
+
336
+
337
+ def parse_huggingface_inference_response(data: Any) -> str:
338
  if isinstance(data, list) and data:
339
  first_item = data[0]
340
 
341
  if isinstance(first_item, dict):
342
+ for key in [
343
+ "generated_text",
344
+ "summary_text",
345
+ "translation_text"
346
+ ]:
347
+ if key in first_item:
348
+ return str(first_item[key])
349
 
350
+ if isinstance(first_item, str):
351
+ return first_item
352
 
353
  if isinstance(data, dict):
354
+ for key in [
355
+ "generated_text",
356
+ "summary_text",
357
+ "translation_text"
358
+ ]:
359
+ if key in data:
360
+ return str(data[key])
361
 
362
  if "error" in data:
363
  return ""
364
 
365
+ if isinstance(data, str):
366
+ return data
367
+
368
  return ""
369
 
370
 
 
374
 
375
  cleaned = answer.strip()
376
 
377
+ unwanted_prefixes = [
378
+ "final answer:",
379
+ "answer:",
380
+ "the answer is:",
381
+ "output:"
382
+ ]
383
+
384
+ for prefix in unwanted_prefixes:
385
+ if cleaned.lower().startswith(prefix):
386
+ cleaned = cleaned[len(prefix):].strip()
387
+
388
  cleaned = re.sub(r"\s+", " ", cleaned)
389
  cleaned = cleaned.replace(" .", ".")
390
  cleaned = cleaned.replace(" ,", ",")
scripts/patch_hf_api_mode.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ path = Path("app/core/config.py")
4
+ text = path.read_text(encoding="utf-8")
5
+
6
+ if "HF_API_MODE" not in text:
7
+ old = ' HF_TIMEOUT_SECONDS: int = get_int_env("HF_TIMEOUT_SECONDS", 60)\n'
8
+
9
+ new = ''' HF_TIMEOUT_SECONDS: int = get_int_env("HF_TIMEOUT_SECONDS", 60)
10
+
11
+ # auto = try best route based on model name
12
+ # chat = force router chat-completions API
13
+ # inference = force HF inference model endpoint
14
+ HF_API_MODE: str = os.getenv("HF_API_MODE", "auto")
15
+ '''
16
+
17
+ if old in text:
18
+ text = text.replace(old, new)
19
+ path.write_text(text, encoding="utf-8")
20
+ print("HF_API_MODE added to config.py")
21
+ else:
22
+ print("Target line not found. config.py was not changed.")
23
+ else:
24
+ print("HF_API_MODE already exists in config.py")