Shantanu commited on
Commit
581ff4f
·
1 Parent(s): e821987

Cascading LLM fallback: Groq 8b-instant primary → Gemini Flash fallback

Browse files

- Primary model switched from llama-3.3-70b-versatile to llama-3.1-8b-instant.
Groq's daily token quota on 8b is 5x higher than 70b.
- New module recommender/llm.py: single chat_completion_from_messages()
that tries Groq first, falls back to Gemini 1.5 Flash on rate-limit or
any other exception. Translates OpenAI-style messages -> Gemini API.
- slots.py, explain.py, chat.py: refactored to use the unified interface.
- app.py: /chat endpoint wraps the agent call in try/except so complete
LLM outage returns a friendly chat-type response instead of 500.

Requires HF Space secret: GEMINI_API_KEY (get at aistudio.google.com/apikey)

app.py CHANGED
@@ -111,13 +111,28 @@ def health() -> Dict[str, Any]:
111
 
112
  @app.post("/chat")
113
  def chat(req: ChatIn) -> Dict[str, Any]:
114
- return get_agent().respond(
115
- user_id=req.user_id,
116
- query=req.message,
117
- conversation_history=req.history or [],
118
- recent_games=req.recent_games or [],
119
- top_n=req.top_n,
120
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
 
123
  @app.post("/feedback")
 
111
 
112
  @app.post("/chat")
113
  def chat(req: ChatIn) -> Dict[str, Any]:
114
+ try:
115
+ return get_agent().respond(
116
+ user_id=req.user_id,
117
+ query=req.message,
118
+ conversation_history=req.history or [],
119
+ recent_games=req.recent_games or [],
120
+ top_n=req.top_n,
121
+ )
122
+ except Exception as e:
123
+ # LLM providers exhausted (both Groq + Gemini down / rate-limited).
124
+ # Return a friendly response instead of 500 so the frontend can show
125
+ # a nice message instead of an error stack.
126
+ return {
127
+ "type": "chat",
128
+ "message": (
129
+ "The genie is resting — both AI providers hit their limits. "
130
+ "Try again in a minute, or browse by genre."
131
+ ),
132
+ "slots": {"intent": "chat", "search_text": req.message},
133
+ "recommendations": [],
134
+ "error": str(e)[:200],
135
+ }
136
 
137
 
138
  @app.post("/feedback")
recommender/chat.py CHANGED
@@ -11,12 +11,9 @@ discuss games / gaming / this recommender, and to redirect anything else.
11
  Off-topic messages are short-circuited to a canned response so we don't pay
12
  for LLM calls on "what's the weather".
13
  """
14
- import os
15
  from typing import List, Dict, Optional
16
 
17
- from groq import Groq
18
-
19
- MODEL = "llama-3.3-70b-versatile"
20
 
21
  OFF_TOPIC_REPLY = (
22
  "I'm a game recommender — I only chat about games. "
@@ -37,19 +34,6 @@ STYLE:
37
 
38
  If the user is asking about specific games shown in RECENT_RECS, answer using ONLY that metadata. Do not fabricate details."""
39
 
40
- _client: Optional[Groq] = None
41
-
42
-
43
- def _get_client() -> Groq:
44
- global _client
45
- if _client is None:
46
- api_key = os.environ.get("GROQ_API_KEY")
47
- if not api_key:
48
- raise RuntimeError("GROQ_API_KEY not set")
49
- _client = Groq(api_key=api_key)
50
- return _client
51
-
52
-
53
  def off_topic_reply() -> str:
54
  """No LLM call — canned polite redirect."""
55
  return OFF_TOPIC_REPLY
@@ -86,10 +70,9 @@ def chat_reply(
86
 
87
  messages.append({"role": "user", "content": query})
88
 
89
- resp = _get_client().chat.completions.create(
90
- model=MODEL,
91
- messages=messages,
92
  temperature=0.4,
93
  max_tokens=250,
94
  )
95
- return (resp.choices[0].message.content or "").strip() or OFF_TOPIC_REPLY
 
11
  Off-topic messages are short-circuited to a canned response so we don't pay
12
  for LLM calls on "what's the weather".
13
  """
 
14
  from typing import List, Dict, Optional
15
 
16
+ from .llm import chat_completion_from_messages
 
 
17
 
18
  OFF_TOPIC_REPLY = (
19
  "I'm a game recommender — I only chat about games. "
 
34
 
35
  If the user is asking about specific games shown in RECENT_RECS, answer using ONLY that metadata. Do not fabricate details."""
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def off_topic_reply() -> str:
38
  """No LLM call — canned polite redirect."""
39
  return OFF_TOPIC_REPLY
 
70
 
71
  messages.append({"role": "user", "content": query})
72
 
73
+ reply = chat_completion_from_messages(
74
+ messages,
 
75
  temperature=0.4,
76
  max_tokens=250,
77
  )
78
+ return reply or OFF_TOPIC_REPLY
recommender/explain.py CHANGED
@@ -10,24 +10,9 @@ Concepts:
10
  and faster; the model sees the full slate and can differentiate rationales.
11
  """
12
  import json
13
- import os
14
  from typing import List, Optional
15
 
16
- from groq import Groq
17
-
18
- MODEL = "llama-3.3-70b-versatile"
19
-
20
- _client: Optional[Groq] = None
21
-
22
-
23
- def _get_client() -> Groq:
24
- global _client
25
- if _client is None:
26
- api_key = os.environ.get("GROQ_API_KEY")
27
- if not api_key:
28
- raise RuntimeError("GROQ_API_KEY not set in env")
29
- _client = Groq(api_key=api_key)
30
- return _client
31
 
32
 
33
  SYSTEM_PROMPT = (
@@ -69,9 +54,8 @@ def generate_rationales(
69
  )
70
 
71
  try:
72
- resp = _get_client().chat.completions.create(
73
- model=MODEL,
74
- messages=[
75
  {"role": "system", "content": SYSTEM_PROMPT},
76
  {"role": "user", "content": user_prompt},
77
  ],
@@ -79,7 +63,7 @@ def generate_rationales(
79
  temperature=0.4,
80
  max_tokens=800,
81
  )
82
- data = json.loads(resp.choices[0].message.content or "{}")
83
  by_id = {int(r["id"]): str(r["text"]) for r in data.get("rationales", []) if "id" in r and "text" in r}
84
  except (json.JSONDecodeError, KeyError, ValueError):
85
  by_id = {}
@@ -113,16 +97,14 @@ def explain_game_for_user(game: dict, liked_games: Optional[List[dict]] = None)
113
  "- Paragraph 2 (50-70 words): whether it's a fit for THIS user. Reference past likes if relevant.\n"
114
  "- Ground every claim in the actual metadata.\n"
115
  )
116
- resp = _get_client().chat.completions.create(
117
- model=MODEL,
118
- messages=[
119
  {"role": "system", "content": EXPLAIN_GAME_SYSTEM},
120
  {"role": "user", "content": prompt},
121
  ],
122
  temperature=0.5,
123
  max_tokens=400,
124
  )
125
- return (resp.choices[0].message.content or "").strip()
126
 
127
 
128
  # ---------- Genre deep-dive ----------
@@ -147,16 +129,14 @@ def explain_genre(genre_name: str, examples: Optional[List[dict]] = None) -> str
147
  "- Written for someone who's heard the term but doesn't know it deeply.\n"
148
  "- No 'in conclusion' phrases, no marketing tone."
149
  )
150
- resp = _get_client().chat.completions.create(
151
- model=MODEL,
152
- messages=[
153
  {"role": "system", "content": GENRE_EXPLAIN_SYSTEM},
154
  {"role": "user", "content": prompt},
155
  ],
156
  temperature=0.4,
157
  max_tokens=400,
158
  )
159
- return (resp.choices[0].message.content or "").strip()
160
 
161
 
162
  # ---------- Taste summary ----------
@@ -187,13 +167,11 @@ def taste_summary(liked_games: List[dict]) -> str:
187
  "- Reference specific tags/vibes that repeat.\n"
188
  "- No 'overall', 'in conclusion', 'seems like'. Direct observations only."
189
  )
190
- resp = _get_client().chat.completions.create(
191
- model=MODEL,
192
- messages=[
193
  {"role": "system", "content": TASTE_SUMMARY_SYSTEM},
194
  {"role": "user", "content": prompt},
195
  ],
196
  temperature=0.4,
197
  max_tokens=200,
198
  )
199
- return (resp.choices[0].message.content or "").strip()
 
10
  and faster; the model sees the full slate and can differentiate rationales.
11
  """
12
  import json
 
13
  from typing import List, Optional
14
 
15
+ from .llm import chat_completion_from_messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  SYSTEM_PROMPT = (
 
54
  )
55
 
56
  try:
57
+ raw = chat_completion_from_messages(
58
+ [
 
59
  {"role": "system", "content": SYSTEM_PROMPT},
60
  {"role": "user", "content": user_prompt},
61
  ],
 
63
  temperature=0.4,
64
  max_tokens=800,
65
  )
66
+ data = json.loads(raw or "{}")
67
  by_id = {int(r["id"]): str(r["text"]) for r in data.get("rationales", []) if "id" in r and "text" in r}
68
  except (json.JSONDecodeError, KeyError, ValueError):
69
  by_id = {}
 
97
  "- Paragraph 2 (50-70 words): whether it's a fit for THIS user. Reference past likes if relevant.\n"
98
  "- Ground every claim in the actual metadata.\n"
99
  )
100
+ return chat_completion_from_messages(
101
+ [
 
102
  {"role": "system", "content": EXPLAIN_GAME_SYSTEM},
103
  {"role": "user", "content": prompt},
104
  ],
105
  temperature=0.5,
106
  max_tokens=400,
107
  )
 
108
 
109
 
110
  # ---------- Genre deep-dive ----------
 
129
  "- Written for someone who's heard the term but doesn't know it deeply.\n"
130
  "- No 'in conclusion' phrases, no marketing tone."
131
  )
132
+ return chat_completion_from_messages(
133
+ [
 
134
  {"role": "system", "content": GENRE_EXPLAIN_SYSTEM},
135
  {"role": "user", "content": prompt},
136
  ],
137
  temperature=0.4,
138
  max_tokens=400,
139
  )
 
140
 
141
 
142
  # ---------- Taste summary ----------
 
167
  "- Reference specific tags/vibes that repeat.\n"
168
  "- No 'overall', 'in conclusion', 'seems like'. Direct observations only."
169
  )
170
+ return chat_completion_from_messages(
171
+ [
 
172
  {"role": "system", "content": TASTE_SUMMARY_SYSTEM},
173
  {"role": "user", "content": prompt},
174
  ],
175
  temperature=0.4,
176
  max_tokens=200,
177
  )
 
recommender/llm.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unified LLM interface with cascading fallback.
3
+
4
+ Order of tries:
5
+ 1. Groq llama-3.1-8b-instant — primary. 5x the daily quota of 70B.
6
+ 2. Google Gemini 1.5 Flash — fallback. Separate 1M tokens/day free tier.
7
+
8
+ Callers use one function: `chat_completion_from_messages(...)`.
9
+ Same messages format both underlying APIs, no model-specific logic outside this file.
10
+ """
11
+ import os
12
+ import time
13
+ from typing import Dict, List, Optional
14
+
15
+ # Groq
16
+ from groq import Groq
17
+ try:
18
+ # Groq SDK exposes rate limit exceptions in these paths across versions.
19
+ from groq import RateLimitError as _GroqRateLimit # type: ignore
20
+ except ImportError:
21
+ try:
22
+ from groq._exceptions import RateLimitError as _GroqRateLimit # type: ignore
23
+ except ImportError:
24
+ _GroqRateLimit = Exception # type: ignore
25
+
26
+ # Gemini
27
+ import google.generativeai as genai
28
+
29
+ # Model IDs
30
+ GROQ_PRIMARY_MODEL = "llama-3.1-8b-instant" # higher daily TPD than 70B
31
+ GEMINI_MODEL = "gemini-1.5-flash" # free 1M tokens/day
32
+
33
+ _groq_client: Optional[Groq] = None
34
+ _gemini_configured = False
35
+
36
+
37
+ def _get_groq() -> Groq:
38
+ global _groq_client
39
+ if _groq_client is None:
40
+ key = os.environ.get("GROQ_API_KEY")
41
+ if not key:
42
+ raise RuntimeError("GROQ_API_KEY not set")
43
+ _groq_client = Groq(api_key=key)
44
+ return _groq_client
45
+
46
+
47
+ def _ensure_gemini() -> None:
48
+ global _gemini_configured
49
+ if _gemini_configured:
50
+ return
51
+ key = os.environ.get("GEMINI_API_KEY")
52
+ if not key:
53
+ raise RuntimeError("GEMINI_API_KEY not set")
54
+ genai.configure(api_key=key)
55
+ _gemini_configured = True
56
+
57
+
58
+ def _log(msg: str) -> None:
59
+ print(f"[llm] {msg}", flush=True)
60
+
61
+
62
+ def chat_completion_from_messages(
63
+ messages: List[Dict],
64
+ response_format: Optional[Dict] = None,
65
+ temperature: float = 0.4,
66
+ max_tokens: int = 500,
67
+ ) -> str:
68
+ """
69
+ Try Groq first, fall back to Gemini on rate limit or any exception.
70
+ Returns the response text (or empty string on complete failure).
71
+ """
72
+ # ---- 1. Groq ----
73
+ try:
74
+ resp = _get_groq().chat.completions.create(
75
+ model=GROQ_PRIMARY_MODEL,
76
+ messages=messages,
77
+ response_format=response_format,
78
+ temperature=temperature,
79
+ max_tokens=max_tokens,
80
+ )
81
+ return (resp.choices[0].message.content or "").strip()
82
+ except _GroqRateLimit as e:
83
+ _log(f"Groq rate-limited, falling back to Gemini: {e}")
84
+ except Exception as e:
85
+ _log(f"Groq errored ({type(e).__name__}), falling back to Gemini: {e}")
86
+
87
+ # ---- 2. Gemini fallback ----
88
+ try:
89
+ return _gemini_from_messages(
90
+ messages,
91
+ response_format=response_format,
92
+ temperature=temperature,
93
+ max_tokens=max_tokens,
94
+ )
95
+ except Exception as e:
96
+ _log(f"Gemini fallback also failed ({type(e).__name__}): {e}")
97
+ # Return empty; callers should handle gracefully
98
+ return ""
99
+
100
+
101
+ def _gemini_from_messages(
102
+ messages: List[Dict],
103
+ response_format: Optional[Dict],
104
+ temperature: float,
105
+ max_tokens: int,
106
+ ) -> str:
107
+ """Translate OpenAI-style messages → Gemini API call."""
108
+ _ensure_gemini()
109
+
110
+ # Split system content out; Gemini uses `system_instruction`, not a message role.
111
+ system_texts: List[str] = []
112
+ convo: List[Dict] = []
113
+ for m in messages:
114
+ role = m.get("role")
115
+ content = m.get("content") or ""
116
+ if role == "system":
117
+ system_texts.append(content)
118
+ elif role == "user":
119
+ convo.append({"role": "user", "parts": [content]})
120
+ elif role == "assistant":
121
+ convo.append({"role": "model", "parts": [content]})
122
+
123
+ system_instruction = "\n\n".join(system_texts) if system_texts else None
124
+
125
+ gen_config: Dict = {
126
+ "temperature": temperature,
127
+ "max_output_tokens": max_tokens,
128
+ }
129
+ if response_format and response_format.get("type") == "json_object":
130
+ gen_config["response_mime_type"] = "application/json"
131
+
132
+ model = genai.GenerativeModel(
133
+ model_name=GEMINI_MODEL,
134
+ system_instruction=system_instruction,
135
+ generation_config=gen_config,
136
+ )
137
+
138
+ if not convo:
139
+ return ""
140
+
141
+ if len(convo) == 1:
142
+ resp = model.generate_content(convo[0]["parts"][0])
143
+ else:
144
+ chat = model.start_chat(history=convo[:-1])
145
+ resp = chat.send_message(convo[-1]["parts"][0])
146
+
147
+ # Gemini can return responses with no text (safety filter, etc.)
148
+ text = getattr(resp, "text", None) or ""
149
+ return text.strip()
recommender/slots.py CHANGED
@@ -12,24 +12,9 @@ Concepts:
12
  We only surface this when it says so — otherwise recommend.
13
  """
14
  import json
15
- import os
16
  from typing import Optional, List, Dict
17
 
18
- from groq import Groq
19
-
20
- MODEL = "llama-3.3-70b-versatile"
21
-
22
- _client: Optional[Groq] = None
23
-
24
-
25
- def _get_client() -> Groq:
26
- global _client
27
- if _client is None:
28
- api_key = os.environ.get("GROQ_API_KEY")
29
- if not api_key:
30
- raise RuntimeError("GROQ_API_KEY not set in env")
31
- _client = Groq(api_key=api_key)
32
- return _client
33
 
34
 
35
  SYSTEM_PROMPT = """You are a game recommendation assistant. Parse each user message into a JSON object with EXACTLY these fields:
@@ -93,14 +78,12 @@ def extract_slots(query: str, conversation_history: Optional[List[Dict]] = None)
93
  messages.extend(conversation_history[-6:])
94
  messages.append({"role": "user", "content": query})
95
 
96
- resp = _get_client().chat.completions.create(
97
- model=MODEL,
98
- messages=messages,
99
  response_format={"type": "json_object"},
100
  temperature=0.2,
101
  max_tokens=400,
102
- )
103
- raw = resp.choices[0].message.content or "{}"
104
  try:
105
  slots = json.loads(raw)
106
  if not isinstance(slots, dict):
 
12
  We only surface this when it says so — otherwise recommend.
13
  """
14
  import json
 
15
  from typing import Optional, List, Dict
16
 
17
+ from .llm import chat_completion_from_messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  SYSTEM_PROMPT = """You are a game recommendation assistant. Parse each user message into a JSON object with EXACTLY these fields:
 
78
  messages.extend(conversation_history[-6:])
79
  messages.append({"role": "user", "content": query})
80
 
81
+ raw = chat_completion_from_messages(
82
+ messages,
 
83
  response_format={"type": "json_object"},
84
  temperature=0.2,
85
  max_tokens=400,
86
+ ) or "{}"
 
87
  try:
88
  slots = json.loads(raw)
89
  if not isinstance(slots, dict):
requirements.txt CHANGED
@@ -12,6 +12,7 @@ rank-bm25>=0.2.2
12
 
13
  # --- LLM ---
14
  groq>=0.11.0
 
15
  python-dotenv>=1.0.0
16
 
17
  # --- Web (Phase 2) ---
 
12
 
13
  # --- LLM ---
14
  groq>=0.11.0
15
+ google-generativeai>=0.8.0
16
  python-dotenv>=1.0.0
17
 
18
  # --- Web (Phase 2) ---