bivav's picture
Simplify comments
1782cbc
Raw History Blame Contribute Delete
20.7 kB
"""Gradio chat client for a Hugging Face Inference Endpoint."""
import itertools
import json
import logging
import os
import sys
import time
from secrets import compare_digest
from typing import NamedTuple
import gradio as gr
import requests
# Root stays at WARNING so LOG_LEVEL doesn't also enable httpx/urllib3.
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger("gemma-endpoint")
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO").upper())
_turn_ids = itertools.count(1)
class EndpointWaking(Exception):
"""Raised before any tokens, so retrying the request is safe."""
REQUEST_TIMEOUT_SECONDS = 300
MAX_TOKENS = 4096 # reasoning models spend most of a smaller budget thinking
# A cold endpoint rejects outright instead of holding the request.
WAKE_STATUSES = (502, 503, 504)
AUTH_STATUSES = (401, 403)
WAKE_TIMEOUT_SECONDS = int(os.environ.get("WAKE_TIMEOUT_SECONDS", "300"))
WAKE_RETRY_SECONDS = 5
WAKE_TICK_SECONDS = 1 # shorter than the retry so Stop stays responsive
# `status: pending` renders a spinner. It needs text: reasoning_tags drops
# empty messages, spinner included.
WAITING_METADATA = {"title": "Waiting for the endpoint…", "status": "pending"}
WAITING_TEXT = "If the endpoint was asleep, the first reply can take a few minutes."
INPUT_PLACEHOLDER = "Message the model"
# llama.cpp sends thinking as reasoning_content; these tags make it collapsible.
THINK_OPEN = "<think>"
THINK_CLOSE = "</think>"
# The default 512-byte reads deliver the whole reply at once.
STREAM_CHUNK_SIZE = 1
GEMMA_MODEL = "google/gemma-4-12B-it-qat-q4_0-gguf"
QWEN_MODEL = "unsloth/Qwen3.5-9B-GGUF"
DEEPSEEK_MODEL = "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B"
MODEL_LABELS = {
GEMMA_MODEL: "Gemma 4 12B",
QWEN_MODEL: "Qwen 3.5 9B",
DEEPSEEK_MODEL: "DeepSeek R1 14B",
}
def _required_env(name):
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(
f"{name} is not set. Set it as a Space secret (or export it "
f"locally) before starting the app."
)
return value
class Endpoint(NamedTuple):
url: str
token: str
token_env: str # secret name, for logs and errors; never the token itself
def _endpoint(url_env, token_env):
"""Per-org token, falling back to HF_TOKEN."""
token = os.environ.get(token_env, "").strip()
return Endpoint(
url=_required_env(url_env).rstrip("/"),
token=token or HF_TOKEN,
token_env=token_env if token else "HF_TOKEN",
)
HF_TOKEN = _required_env("HF_TOKEN")
ENDPOINT_URL = _required_env("ENDPOINT_URL").rstrip("/")
LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "")
MODELS = {
GEMMA_MODEL: Endpoint(ENDPOINT_URL, HF_TOKEN, "HF_TOKEN"),
QWEN_MODEL: _endpoint("QWEN_ENDPOINT_URL", "QWEN_HF_TOKEN"),
DEEPSEEK_MODEL: _endpoint("DEEPSEEK_ENDPOINT_URL", "DEEPSEEK_HF_TOKEN"),
}
AVAILABLE_MODELS = list(MODELS)
DEFAULT_MODEL = GEMMA_MODEL
def _log_config():
for model, endpoint in MODELS.items():
logger.info(
"configured model=%s url=%s token=%s",
model,
endpoint.url,
endpoint.token_env,
)
logger.info(
"configured auth=%s timeout=%ss max_tokens=%s",
"on" if LOGIN_PASSWORD else "OFF",
REQUEST_TIMEOUT_SECONDS,
MAX_TOKENS,
)
_log_config()
def endpoint_for(model_name):
return MODELS.get(model_name) or Endpoint(ENDPOINT_URL, HF_TOKEN, "HF_TOKEN")
def message_text(content):
"""Gradio content is a string or a list of typed parts; keep the text."""
if isinstance(content, str):
return content
if isinstance(content, (list, tuple)):
return "".join(
part.get("text") or ""
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return ""
def normalize_history(history):
"""Plain text turns, without spinners or other metadata messages."""
turns = []
for item in history or []:
if not isinstance(item, dict) or item.get("role") not in ("user", "assistant"):
continue
if (item.get("metadata") or {}).get("title"):
continue
text = message_text(item.get("content"))
if text:
turns.append({"role": item["role"], "content": text})
return turns
def build_api_messages(history, message):
return normalize_history(history) + [{"role": "user", "content": message}]
def stream_completion(messages, model_name, turn="-"):
"""Yield SSE chunks. reasoning_content is wrapped in think tags."""
endpoint = endpoint_for(model_name)
prompt_chars = sum(len(m["content"]) for m in messages)
logger.info(
"turn=%s POST %s/v1/chat/completions model=%s token=%s messages=%d "
"prompt_chars=%d",
turn,
endpoint.url,
model_name,
endpoint.token_env,
len(messages),
prompt_chars,
)
logger.debug("turn=%s prompt=%s", turn, messages)
started = time.monotonic()
with requests.post(
f"{endpoint.url}/v1/chat/completions",
headers={
"Authorization": f"Bearer {endpoint.token}",
"Content-Type": "application/json",
},
json={
"model": model_name,
"messages": messages,
"max_tokens": MAX_TOKENS,
"stream": True,
},
timeout=REQUEST_TIMEOUT_SECONDS,
stream=True,
) as response:
logger.info(
"turn=%s response status=%s after %.2fs",
turn,
response.status_code,
time.monotonic() - started,
)
if response.status_code in WAKE_STATUSES:
raise EndpointWaking(response.status_code)
if response.status_code in AUTH_STATUSES:
raise gr.Error(
f"{MODEL_LABELS.get(model_name, model_name)} rejected the token "
f"(HTTP {response.status_code}). Check that {endpoint.token_env} "
f"has access to the organisation that owns this endpoint."
)
response.raise_for_status()
# requests guesses ISO-8859-1 when the charset is missing.
response.encoding = "utf-8"
chunks = 0
thinking = False
for line in response.iter_lines(
chunk_size=STREAM_CHUNK_SIZE, decode_unicode=True
):
if not line or not line.startswith("data:"):
continue
payload = line[len("data:") :].strip()
if payload == "[DONE]":
logger.info(
"turn=%s stream done chunks=%d in %.2fs",
turn,
chunks,
time.monotonic() - started,
)
break
choices = json.loads(payload).get("choices") or [{}]
delta = choices[0].get("delta") or {}
reasoning = delta.get("reasoning_content")
content = delta.get("content")
piece = ""
if reasoning:
if not thinking:
piece += THINK_OPEN
thinking = True
piece += reasoning
if content:
if thinking:
piece += THINK_CLOSE
thinking = False
piece += content
if piece:
chunks += 1
if chunks == 1:
logger.info(
"turn=%s first token after %.2fs",
turn,
time.monotonic() - started,
)
yield piece
else:
logger.warning(
"turn=%s stream ended without [DONE] after %d chunks", turn, chunks
)
if thinking:
yield THINK_CLOSE
def waiting_turn(title=None):
metadata = dict(WAITING_METADATA)
if title:
metadata["title"] = title
return {"role": "assistant", "content": WAITING_TEXT, "metadata": metadata}
def stream_turn(messages, model_name, turn="-"):
"""Yield ("waking", seconds) while the endpoint is cold, then ("token", text)."""
started = time.monotonic()
deadline = started + WAKE_TIMEOUT_SECONDS
attempt = 0
while True:
attempt += 1
try:
for chunk in stream_completion(messages, model_name, turn=turn):
yield "token", chunk
return
except EndpointWaking as waking:
waited = time.monotonic() - started
if time.monotonic() >= deadline:
raise gr.Error(
f"The endpoint did not come up within "
f"{WAKE_TIMEOUT_SECONDS}s (last status {waking}). Check that "
f"it is Running and not scaled to zero."
) from None
logger.info(
"turn=%s endpoint waking status=%s attempt=%d waited=%.0fs",
turn,
waking,
attempt,
waited,
)
for _ in range(WAKE_RETRY_SECONDS // WAKE_TICK_SECONDS):
if time.monotonic() >= deadline:
break
yield "waking", int(time.monotonic() - started)
time.sleep(WAKE_TICK_SECONDS)
def respond(message, history, model_name):
history = list(history or [])
message = (message or "").strip()
if not message:
logger.debug("ignoring an empty submission")
yield history, *idle_controls()
return
turn = next(_turn_ids)
started = time.monotonic()
api_messages = build_api_messages(history, message)
logger.info(
"turn=%s start model=%s history_turns=%d message_chars=%d",
turn,
model_name or DEFAULT_MODEL,
len(api_messages) - 1,
len(message),
)
history.append({"role": "user", "content": message})
history.append(waiting_turn())
yield list(history), *busy_controls()
reply = ""
try:
for kind, value in stream_turn(
api_messages, model_name or DEFAULT_MODEL, turn=turn
):
if kind == "waking":
history[-1] = waiting_turn(f"Waking up the endpoint… {value}s")
else:
reply += value
history[-1] = {"role": "assistant", "content": reply}
yield list(history), *busy_controls()
if not reply:
raise gr.Error("The endpoint returned an empty reply.")
except Exception as error:
if isinstance(error, gr.Error):
detail = str(error)
elif isinstance(error, requests.exceptions.Timeout):
detail = "The endpoint timed out. Check that it is in the Running state."
elif isinstance(error, requests.exceptions.RequestException):
detail = f"Request to the endpoint failed: {_error_detail(error)}"
elif isinstance(error, (KeyError, IndexError, ValueError)):
detail = f"Unexpected response from the endpoint: {error}"
else:
logger.exception("turn=%s unexpected failure", turn)
raise
logger.error(
"turn=%s failed after %.2fs with %d chars streamed: %s",
turn,
time.monotonic() - started,
len(reply),
detail,
)
if reply:
yield list(history), *idle_controls()
else:
del history[-2:]
yield list(history), *idle_controls(message)
raise gr.Error(detail, print_exception=False) from None
logger.info(
"turn=%s complete reply_chars=%d in %.2fs",
turn,
len(reply),
time.monotonic() - started,
)
logger.debug("turn=%s reply=%s", turn, reply)
yield list(history), *idle_controls()
def busy_controls(value=""):
return (
gr.update(value=value, interactive=False, placeholder="Replying…"),
gr.update(interactive=False, visible=False),
gr.update(interactive=True, visible=True),
)
def idle_controls(value=""):
return (
gr.update(value=value, interactive=True, placeholder=INPUT_PLACEHOLDER),
gr.update(interactive=True, visible=True),
gr.update(interactive=False, visible=False),
)
def cancel_stream(history):
logger.info("stopped by the user")
return normalize_history(history), *idle_controls()
def new_chat():
logger.info("new chat")
return [], *idle_controls()
def model_caption(model_name):
return f"`{model_name or DEFAULT_MODEL}`"
def example_text(event: gr.SelectData):
return event.value["text"]
def _error_detail(error):
if error.response is None:
return str(error)
try:
return error.response.text or str(error)
except requests.exceptions.RequestException:
return str(error)
def verify_login(username, password):
allowed = compare_digest(password or "", LOGIN_PASSWORD)
logger.log(
logging.INFO if allowed else logging.WARNING,
"login %s for username=%r",
"succeeded" if allowed else "REJECTED",
username,
)
return allowed
EXAMPLES = [
{"display_text": "Reply in Japanese", "text": "Introduce yourself in Japanese."},
{
"display_text": "Reason step by step",
"text": "Is 1,001 a prime number? Work it out step by step.",
},
{
"display_text": "Write some code",
"text": "Write a Python function that reverses a singly linked list.",
},
{
"display_text": "Keep it short",
"text": "Explain what a hash map is in two sentences.",
},
]
EMPTY_STATE = (
"**Pick a model and send a message**\n\n"
"A sleeping endpoint can take a few minutes to answer the first message."
)
THEME = gr.themes.Base(
primary_hue=gr.themes.colors.teal,
neutral_hue=gr.themes.colors.slate,
font=[gr.themes.GoogleFont("IBM Plex Sans"), "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"],
radius_size=gr.themes.sizes.radius_md,
).set(
body_background_fill="#f2f4f6",
body_background_fill_dark="#0e1216",
block_border_width="0px",
block_shadow="none",
button_primary_background_fill="#0b6e79",
button_primary_background_fill_hover="#095a63",
button_primary_background_fill_dark="#14909d",
button_primary_background_fill_hover_dark="#18a5b3",
button_primary_text_color="#ffffff",
)
CSS = """
/* Gradio caps .main at 640px, which squeezes the chat. */
.gradio-container, .gradio-container > .main {
max-width: 880px !important; width: 100%; margin: 0 auto; }
#header { align-items: center; gap: 12px; }
#header h1 { font-size: 1.35rem; font-weight: 600; margin: 0; }
#header p { margin: 2px 0 0; color: var(--body-text-color-subdued); }
#header button { background: var(--block-background-fill);
border: 1px solid var(--border-color-primary); border-radius: 8px; }
#header button:hover { border-color: var(--button-primary-background-fill); }
#model-picker .wrap { gap: 4px; padding: 4px; border-radius: 10px;
background: var(--block-background-fill);
border: 1px solid var(--border-color-primary); width: fit-content; }
#model-picker label { border: 0 !important; box-shadow: none !important;
background: transparent !important; padding: 6px 14px !important;
border-radius: 7px !important; cursor: pointer; }
#model-picker label.selected { background: var(--button-primary-background-fill) !important;
color: var(--button-primary-text-color) !important; }
#model-picker input[type="radio"] { position: absolute; opacity: 0;
width: 1px; height: 1px; pointer-events: none; }
#model-picker label:has(input:focus-visible) {
outline: 2px solid var(--button-primary-background-fill); outline-offset: 2px; }
#model-id { margin-top: -6px; }
#model-id p, #model-id code { font-size: 0.8rem; color: var(--body-text-color-subdued); }
#model-id code { background: none; border: 0; padding: 0; }
#chat {
background: var(--background-fill-primary) !important;
border: 1px solid var(--border-color-primary) !important;
border-radius: 12px !important; }
#chat .bubble-wrap { background: transparent; }
#chat .placeholder-content { justify-content: center; gap: 20px; padding: 24px; }
#chat .placeholder { flex: 0 0 auto !important; height: auto !important;
text-align: center; }
#chat .placeholder p { margin: 4px 0; color: var(--body-text-color-subdued); }
#chat .placeholder strong { color: var(--body-text-color); font-size: 1.05rem; }
#chat .examples { width: 100%; max-width: 560px; margin: 0 auto; gap: 10px; }
#chat .example { background: var(--background-fill-secondary) !important;
border: 1px solid var(--border-color-primary) !important;
border-radius: 10px !important; text-align: left; }
#chat .example:hover { border-color: var(--button-primary-background-fill) !important; }
#composer { align-items: stretch; gap: 8px; }
#composer textarea { font-size: 0.975rem; padding: 12px 14px !important;
background: var(--block-background-fill) !important;
border: 1px solid var(--border-color-primary) !important;
border-radius: 10px !important; }
#composer textarea:focus { border-color: var(--button-primary-background-fill) !important;
box-shadow: 0 0 0 3px color-mix(in srgb,
var(--button-primary-background-fill) 20%, transparent) !important; }
#composer button { min-width: 88px; border-radius: 10px; }
"""
def create_app():
with gr.Blocks(title="Endpoint chat") as demo:
with gr.Row(elem_id="header", equal_height=True):
gr.Markdown(
"# Endpoint chat\nChat with your Hugging Face Inference Endpoints.",
)
new_chat_button = gr.Button("New chat", size="sm", scale=0, min_width=110)
model_selector = gr.Radio(
choices=[(MODEL_LABELS[model], model) for model in AVAILABLE_MODELS],
value=DEFAULT_MODEL,
label="Model",
show_label=False,
container=False,
elem_id="model-picker",
)
model_id = gr.Markdown(model_caption(DEFAULT_MODEL), elem_id="model-id")
chatbot = gr.Chatbot(
show_label=False,
height="calc(100dvh - 330px)",
min_height=320,
placeholder=EMPTY_STATE,
examples=EXAMPLES,
reasoning_tags=[(THINK_OPEN, THINK_CLOSE)],
buttons=["copy", "copy_all"],
feedback_options=None,
elem_id="chat",
)
with gr.Row(elem_id="composer"):
msg = gr.Textbox(
label="Message",
show_label=False,
placeholder=INPUT_PLACEHOLDER,
lines=1,
max_lines=8,
autofocus=True,
container=False,
scale=1,
)
submit_button = gr.Button("Send", variant="primary", scale=0)
stop_button = gr.Button(
"Stop", variant="stop", scale=0, interactive=False, visible=False
)
controls = [msg, submit_button, stop_button]
chat_outputs = [chatbot, *controls]
respond_inputs = [msg, chatbot, model_selector]
send_events = [
trigger(fn=respond, inputs=respond_inputs, outputs=chat_outputs)
for trigger in (submit_button.click, msg.submit)
]
send_events.append(
chatbot.example_select(fn=example_text, outputs=msg).then(
fn=respond, inputs=respond_inputs, outputs=chat_outputs
)
)
# Cancelling skips the generator's cleanup, so Stop does it itself.
stop_button.click(
fn=cancel_stream,
inputs=[chatbot],
outputs=chat_outputs,
cancels=send_events,
)
new_chat_button.click(fn=new_chat, outputs=chat_outputs, cancels=send_events)
model_selector.change(fn=model_caption, inputs=model_selector, outputs=model_id)
return demo
# Spaces' reloader looks up `demo` in __main__.
demo = create_app()
if __name__ == "__main__":
if not LOGIN_PASSWORD:
print(
"WARNING: LOGIN_PASSWORD is not set - the app is starting without "
"authentication and anyone who can reach it can spend endpoint quota."
)
demo.launch(auth=verify_login if LOGIN_PASSWORD else None, theme=THEME, css=CSS)