Spaces:
Running
Running
Upload groq_proxy_service.py
Browse files- groq_proxy_service.py +67 -79
groq_proxy_service.py
CHANGED
|
@@ -6,8 +6,15 @@ Lightweight FastAPI proxy that exposes a single POST /estimate_audience
|
|
| 6 |
endpoint. Deployed on HuggingFace Spaces (Slot 1 and Slot 2 of the
|
| 7 |
audience estimation pool in music_chart_server.py).
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
This service enforces a strict 1 req/s inter-request gate so that
|
| 13 |
music_chart_server's own 1 req/s per-slot reservation is always met,
|
|
@@ -16,9 +23,6 @@ even if multiple upstream callers arrive simultaneously.
|
|
| 16 |
Environment variables (injected as HF Secrets β never hardcoded):
|
| 17 |
GROQ_API_KEY β the Groq API key for this Space's account (required)
|
| 18 |
|
| 19 |
-
Model:
|
| 20 |
-
llama-3.1-8b-instant (same lightweight model as the native slot)
|
| 21 |
-
|
| 22 |
Usage:
|
| 23 |
uvicorn groq_proxy_service:app --host 0.0.0.0 --port 7860
|
| 24 |
"""
|
|
@@ -31,7 +35,7 @@ import threading
|
|
| 31 |
import time
|
| 32 |
from typing import Optional
|
| 33 |
|
| 34 |
-
from fastapi import FastAPI, HTTPException
|
| 35 |
from fastapi.responses import JSONResponse
|
| 36 |
from groq import Groq
|
| 37 |
from pydantic import BaseModel
|
|
@@ -53,14 +57,12 @@ if not GROQ_API_KEY:
|
|
| 53 |
"All /estimate_audience calls will fail until it is configured."
|
| 54 |
)
|
| 55 |
|
| 56 |
-
#
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
# Minimum seconds between consecutive Groq calls on this instance
|
| 60 |
-
# 1.0 s βΉ 60 RPM ceiling, matching the per-slot reservation in the pool
|
| 61 |
SLOT_INTERVAL: float = float(os.environ.get("SLOT_INTERVAL", "1.0"))
|
| 62 |
|
| 63 |
-
# Maximum tokens in Groq response β audience JSON is tiny
|
| 64 |
MAX_TOKENS: int = int(os.environ.get("MAX_TOKENS", "256"))
|
| 65 |
|
| 66 |
# βββββββββββββββββββββββββββ RATE-LIMIT GATE ββββββββββββββββββββββββββββββββ
|
|
@@ -111,41 +113,6 @@ def _get_client() -> Groq:
|
|
| 111 |
return _groq_client
|
| 112 |
|
| 113 |
|
| 114 |
-
# βββββββββββββββββββββββββββ PROMPT βββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
-
|
| 116 |
-
_PROMPT_TEMPLATE = """\
|
| 117 |
-
Estimate audience distribution by country for artist {ARTIST}, song "{SONG_TITLE}".
|
| 118 |
-
|
| 119 |
-
Return ONLY JSON:
|
| 120 |
-
{{
|
| 121 |
-
"AAA": 0.00,
|
| 122 |
-
"BBB": 0.00,
|
| 123 |
-
"CCC": 0.00,
|
| 124 |
-
"OTHERS": 0.00
|
| 125 |
-
}}
|
| 126 |
-
|
| 127 |
-
Rules:
|
| 128 |
-
- ISO3 country codes
|
| 129 |
-
- Values are decimals, sum = 1.00
|
| 130 |
-
- Top 3 countries + OTHERS only
|
| 131 |
-
- No country > 0.50
|
| 132 |
-
- Highest share = primary song language country (skip if English)
|
| 133 |
-
- If genre is globally distributed (e.g., amapiano, afrobeats, hip-hop, pop), enforce OTHERS β₯ 0.15
|
| 134 |
-
- If not a global genre, OTHERS can be any value
|
| 135 |
-
- Prefer realistic streaming distribution
|
| 136 |
-
- If unsure, distribute evenly
|
| 137 |
-
- No text, JSON only
|
| 138 |
-
Non Negotiable: Return ONLY valid JSON. No text, no explanation, no reasoning.
|
| 139 |
-
"""
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def _build_prompt(artist: str, song_title: str) -> str:
|
| 143 |
-
return _PROMPT_TEMPLATE.format(
|
| 144 |
-
ARTIST=artist,
|
| 145 |
-
SONG_TITLE=song_title or artist,
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
# βββββββββββββββββββββββββββ JSON PARSING βββββββββββββββββββββββββββββββββββ
|
| 150 |
|
| 151 |
def _parse_audience_json(raw: str, artist: str) -> dict:
|
|
@@ -180,21 +147,27 @@ def _parse_audience_json(raw: str, artist: str) -> dict:
|
|
| 180 |
app = FastAPI(
|
| 181 |
title="Groq Audience Proxy",
|
| 182 |
description=(
|
| 183 |
-
"
|
| 184 |
-
"
|
|
|
|
|
|
|
| 185 |
),
|
| 186 |
-
version="
|
| 187 |
)
|
| 188 |
|
| 189 |
|
| 190 |
class AudienceRequest(BaseModel):
|
| 191 |
-
artist:
|
| 192 |
song_title: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
class AudienceResponse(BaseModel):
|
| 196 |
-
result:
|
| 197 |
-
model:
|
| 198 |
wait_seconds: float
|
| 199 |
|
| 200 |
|
|
@@ -202,80 +175,95 @@ class AudienceResponse(BaseModel):
|
|
| 202 |
def health():
|
| 203 |
"""Liveness probe used by HuggingFace Spaces and the upstream pool."""
|
| 204 |
return {
|
| 205 |
-
"status":
|
| 206 |
-
"model": GROQ_MODEL,
|
| 207 |
"slot_interval_seconds": SLOT_INTERVAL,
|
| 208 |
-
"groq_key_configured":
|
|
|
|
| 209 |
}
|
| 210 |
|
| 211 |
|
| 212 |
@app.post("/estimate_audience", response_model=AudienceResponse)
|
| 213 |
def estimate_audience(req: AudienceRequest):
|
| 214 |
"""
|
| 215 |
-
|
|
|
|
| 216 |
|
| 217 |
The gate serialises calls so that Groq receives at most 1 request per
|
| 218 |
-
SLOT_INTERVAL seconds from this Space
|
| 219 |
-
of the lightweight Groq model.
|
| 220 |
|
| 221 |
-
|
| 222 |
{
|
| 223 |
-
"
|
| 224 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
"wait_seconds": 0.0
|
| 226 |
}
|
| 227 |
|
| 228 |
Error responses:
|
| 229 |
-
|
| 230 |
-
because the upstream pool issues at most 1 req/s to this slot)
|
| 231 |
500 β Groq call failed or response could not be parsed
|
| 232 |
"""
|
| 233 |
-
artist
|
| 234 |
song_title = req.song_title.strip()
|
|
|
|
|
|
|
| 235 |
|
| 236 |
if not artist:
|
| 237 |
raise HTTPException(status_code=422, detail="'artist' must not be empty.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
-
log.info(
|
|
|
|
|
|
|
| 240 |
|
| 241 |
# ββ Acquire slot (blocks until window is clear) βββββββββββββββββββββββββββ
|
| 242 |
wait_s = _gate.acquire()
|
| 243 |
if wait_s > 0:
|
| 244 |
-
log.debug(f"Gate wait: {wait_s:.3f}s for '{artist}'")
|
| 245 |
|
| 246 |
-
# ββ Call Groq βββββββββββββββββββ
|
| 247 |
try:
|
| 248 |
client = _get_client()
|
| 249 |
completion = client.chat.completions.create(
|
| 250 |
-
model=
|
| 251 |
-
messages=[
|
| 252 |
-
{"role": "user", "content": _build_prompt(artist, song_title)}
|
| 253 |
-
],
|
| 254 |
temperature=0.4,
|
| 255 |
max_tokens=MAX_TOKENS,
|
| 256 |
stream=False,
|
| 257 |
)
|
| 258 |
raw: str = completion.choices[0].message.content or ""
|
| 259 |
-
log.debug(f"Groq raw response for '{artist}': {raw!r}")
|
| 260 |
except Exception as exc:
|
| 261 |
-
log.error(f"Groq API error for '{artist}': {exc}")
|
| 262 |
raise HTTPException(
|
| 263 |
status_code=500,
|
| 264 |
-
detail=f"Groq API call failed: {exc}",
|
| 265 |
)
|
| 266 |
|
| 267 |
# ββ Parse and return ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 268 |
try:
|
| 269 |
result = _parse_audience_json(raw, artist)
|
| 270 |
except Exception as exc:
|
| 271 |
-
log.error(
|
|
|
|
|
|
|
| 272 |
raise HTTPException(
|
| 273 |
status_code=500,
|
| 274 |
-
detail=f"Failed to parse Groq response: {exc}",
|
| 275 |
)
|
| 276 |
|
| 277 |
-
log.info(f"estimate_audience OK: '{artist}'
|
| 278 |
-
return AudienceResponse(result=result, model=
|
| 279 |
|
| 280 |
|
| 281 |
# βββββββββββββββββββββββββββ ENTRYPOINT βββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 6 |
endpoint. Deployed on HuggingFace Spaces (Slot 1 and Slot 2 of the
|
| 7 |
audience estimation pool in music_chart_server.py).
|
| 8 |
|
| 9 |
+
Design: dumb executor.
|
| 10 |
+
music_chart_server owns ALL model-selection and prompt logic. It sends
|
| 11 |
+
both `model` and `prompt` in every request body so that this service
|
| 12 |
+
simply fires whatever it receives at Groq and returns the raw result.
|
| 13 |
+
This keeps control centralised β updating model preference or prompts
|
| 14 |
+
requires changes in one place only (music_chart_server.py).
|
| 15 |
+
|
| 16 |
+
Rate limits honoured (same Groq free-tier limits apply to all models):
|
| 17 |
+
30 RPM / varies RPD / varies TPM / varies TPD (see music_chart_server config)
|
| 18 |
|
| 19 |
This service enforces a strict 1 req/s inter-request gate so that
|
| 20 |
music_chart_server's own 1 req/s per-slot reservation is always met,
|
|
|
|
| 23 |
Environment variables (injected as HF Secrets β never hardcoded):
|
| 24 |
GROQ_API_KEY β the Groq API key for this Space's account (required)
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
Usage:
|
| 27 |
uvicorn groq_proxy_service:app --host 0.0.0.0 --port 7860
|
| 28 |
"""
|
|
|
|
| 35 |
import time
|
| 36 |
from typing import Optional
|
| 37 |
|
| 38 |
+
from fastapi import FastAPI, HTTPException
|
| 39 |
from fastapi.responses import JSONResponse
|
| 40 |
from groq import Groq
|
| 41 |
from pydantic import BaseModel
|
|
|
|
| 57 |
"All /estimate_audience calls will fail until it is configured."
|
| 58 |
)
|
| 59 |
|
| 60 |
+
# Minimum seconds between consecutive Groq calls on this instance.
|
| 61 |
+
# music_chart_server already enforces 1 req/s per slot, but this gate
|
| 62 |
+
# protects against accidental concurrent upstream callers.
|
|
|
|
|
|
|
| 63 |
SLOT_INTERVAL: float = float(os.environ.get("SLOT_INTERVAL", "1.0"))
|
| 64 |
|
| 65 |
+
# Maximum tokens in Groq response β audience JSON is tiny regardless of model
|
| 66 |
MAX_TOKENS: int = int(os.environ.get("MAX_TOKENS", "256"))
|
| 67 |
|
| 68 |
# βββββββββββββββββββββββββββ RATE-LIMIT GATE ββββββββββββββββββββββββββββββββ
|
|
|
|
| 113 |
return _groq_client
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
# βββββββββββββββββββββββββββ JSON PARSING βββββββββββββββββββββββββββββββββββ
|
| 117 |
|
| 118 |
def _parse_audience_json(raw: str, artist: str) -> dict:
|
|
|
|
| 147 |
app = FastAPI(
|
| 148 |
title="Groq Audience Proxy",
|
| 149 |
description=(
|
| 150 |
+
"Dumb-executor slot proxy for the music_chart_server audience pool. "
|
| 151 |
+
"Model and prompt are supplied by the caller β this service only fires "
|
| 152 |
+
"the request at Groq and returns the result. "
|
| 153 |
+
"Enforces 1 req/s to stay within Groq rate limits."
|
| 154 |
),
|
| 155 |
+
version="2.0.0",
|
| 156 |
)
|
| 157 |
|
| 158 |
|
| 159 |
class AudienceRequest(BaseModel):
|
| 160 |
+
artist: str
|
| 161 |
song_title: str = ""
|
| 162 |
+
# Centralised control fields β sent by music_chart_server on every request.
|
| 163 |
+
# The proxy uses these directly; it never picks models or builds prompts itself.
|
| 164 |
+
model: str = "" # e.g. "llama-3.1-8b-instant", "llama-3.3-70b-versatile"
|
| 165 |
+
prompt: str = "" # fully-rendered prompt string from music_chart_server
|
| 166 |
|
| 167 |
|
| 168 |
class AudienceResponse(BaseModel):
|
| 169 |
+
result: dict
|
| 170 |
+
model: str
|
| 171 |
wait_seconds: float
|
| 172 |
|
| 173 |
|
|
|
|
| 175 |
def health():
|
| 176 |
"""Liveness probe used by HuggingFace Spaces and the upstream pool."""
|
| 177 |
return {
|
| 178 |
+
"status": "ok",
|
|
|
|
| 179 |
"slot_interval_seconds": SLOT_INTERVAL,
|
| 180 |
+
"groq_key_configured": bool(GROQ_API_KEY),
|
| 181 |
+
"note": "model selected by music_chart_server per request",
|
| 182 |
}
|
| 183 |
|
| 184 |
|
| 185 |
@app.post("/estimate_audience", response_model=AudienceResponse)
|
| 186 |
def estimate_audience(req: AudienceRequest):
|
| 187 |
"""
|
| 188 |
+
Execute an audience estimation Groq call using the model and prompt
|
| 189 |
+
supplied by music_chart_server in the request body.
|
| 190 |
|
| 191 |
The gate serialises calls so that Groq receives at most 1 request per
|
| 192 |
+
SLOT_INTERVAL seconds from this Space.
|
|
|
|
| 193 |
|
| 194 |
+
Request body:
|
| 195 |
{
|
| 196 |
+
"artist": "Artist Name",
|
| 197 |
+
"song_title": "Track Title",
|
| 198 |
+
"model": "llama-3.1-8b-instant", <- chosen by music_chart_server
|
| 199 |
+
"prompt": "<full rendered prompt>" <- built by music_chart_server
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
Response:
|
| 203 |
+
{
|
| 204 |
+
"result": {"USA": 0.35, "GBR": 0.25, ...},
|
| 205 |
+
"model": "llama-3.1-8b-instant",
|
| 206 |
"wait_seconds": 0.0
|
| 207 |
}
|
| 208 |
|
| 209 |
Error responses:
|
| 210 |
+
422 β missing required fields
|
|
|
|
| 211 |
500 β Groq call failed or response could not be parsed
|
| 212 |
"""
|
| 213 |
+
artist = req.artist.strip()
|
| 214 |
song_title = req.song_title.strip()
|
| 215 |
+
model = req.model.strip()
|
| 216 |
+
prompt = req.prompt.strip()
|
| 217 |
|
| 218 |
if not artist:
|
| 219 |
raise HTTPException(status_code=422, detail="'artist' must not be empty.")
|
| 220 |
+
if not model:
|
| 221 |
+
raise HTTPException(status_code=422, detail="'model' must not be empty.")
|
| 222 |
+
if not prompt:
|
| 223 |
+
raise HTTPException(status_code=422, detail="'prompt' must not be empty.")
|
| 224 |
|
| 225 |
+
log.info(
|
| 226 |
+
f"estimate_audience: artist='{artist}' song='{song_title}' model='{model}'"
|
| 227 |
+
)
|
| 228 |
|
| 229 |
# ββ Acquire slot (blocks until window is clear) βββββββββββββββββββββββββββ
|
| 230 |
wait_s = _gate.acquire()
|
| 231 |
if wait_s > 0:
|
| 232 |
+
log.debug(f"Gate wait: {wait_s:.3f}s for '{artist}' model='{model}'")
|
| 233 |
|
| 234 |
+
# ββ Call Groq with the caller-supplied model and prompt βββββββββββββββββββ
|
| 235 |
try:
|
| 236 |
client = _get_client()
|
| 237 |
completion = client.chat.completions.create(
|
| 238 |
+
model=model,
|
| 239 |
+
messages=[{"role": "user", "content": prompt}],
|
|
|
|
|
|
|
| 240 |
temperature=0.4,
|
| 241 |
max_tokens=MAX_TOKENS,
|
| 242 |
stream=False,
|
| 243 |
)
|
| 244 |
raw: str = completion.choices[0].message.content or ""
|
| 245 |
+
log.debug(f"Groq raw response for '{artist}' model='{model}': {raw!r}")
|
| 246 |
except Exception as exc:
|
| 247 |
+
log.error(f"Groq API error for '{artist}' model='{model}': {exc}")
|
| 248 |
raise HTTPException(
|
| 249 |
status_code=500,
|
| 250 |
+
detail=f"Groq API call failed (model={model}): {exc}",
|
| 251 |
)
|
| 252 |
|
| 253 |
# ββ Parse and return ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 254 |
try:
|
| 255 |
result = _parse_audience_json(raw, artist)
|
| 256 |
except Exception as exc:
|
| 257 |
+
log.error(
|
| 258 |
+
f"JSON parse error for '{artist}' model='{model}': {exc} raw={raw!r}"
|
| 259 |
+
)
|
| 260 |
raise HTTPException(
|
| 261 |
status_code=500,
|
| 262 |
+
detail=f"Failed to parse Groq response (model={model}): {exc}",
|
| 263 |
)
|
| 264 |
|
| 265 |
+
log.info(f"estimate_audience OK: '{artist}' model='{model}' -> {result}")
|
| 266 |
+
return AudienceResponse(result=result, model=model, wait_seconds=wait_s)
|
| 267 |
|
| 268 |
|
| 269 |
# βββββββββββββββββββββββββββ ENTRYPOINT βββββββββββββββββββββββββββββββββββββ
|