loan-collection / src /clients.py
utkarshshukla2912's picture
added changes
e49928e
Raw
History Blame Contribute Delete
14.6 kB
"""Backend clients for the comparison playground.
Exposes a single backend-agnostic streaming generator, `stream_backend`, that
the UI uses for every model. Supports two backend types:
- custom : POST to BASE_URL with x-api-key; endpoint_id + model in the body
(any OpenAI-compatible chat-completions endpoint).
- azure : Azure OpenAI via the openai SDK (base_url = endpoint/openai/v1/).
Also keeps `build_messages`, `get_api_key`, `chat_completion`, `stream_chat`, and
`BackendError` as the lower-level single-backend helpers.
"""
import json
import os
import time
import requests
from dotenv import load_dotenv
import config
# Load a local .env if present. On Hugging Face Spaces secrets are real env
# vars, so this is a harmless no-op there.
load_dotenv()
class BackendError(RuntimeError):
"""Raised for configuration or API errors so the UI can show a clear message."""
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def get_api_key() -> str:
"""Return the custom backend's API key from the environment, or raise."""
key = os.environ.get("BACKEND_API_KEY", "").strip()
if not key:
raise BackendError(
"BACKEND_API_KEY is not set. Add it to a local .env file "
"(see .env.example) or as a Hugging Face Space Secret."
)
return key
def build_messages(system_prompt: str, intro: str, history: list) -> list:
"""Assemble the OpenAI-style messages list.
- `system_prompt`: optional system message prepended first.
- `intro`: optional assistant message seeded as the first assistant turn.
- `history`: list of {"role", "content"} dicts, already excluding the seeded
intro bubble.
"""
messages = []
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt.strip()})
if intro and intro.strip():
messages.append({"role": "assistant", "content": intro.strip()})
for turn in history:
role = turn.get("role")
content = turn.get("content")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
return messages
def _empty_metrics(**overrides) -> dict:
base = {
"__metrics__": True,
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
"cached_tokens": None,
"latency_s": None,
# Name of the tool the model invoked this turn (e.g. "end_call"), or
# None when the reply was ordinary text. Lets the UI/logs flag tool use.
"tool_called": None,
# The exact assistant message the model produced this turn, as an
# OpenAI-style {"content", "tool_calls"} dict (raw tool arguments kept
# verbatim). Powers the debug panel; None on error.
"raw_response": None,
"error": None,
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Speech-to-Text — voice input (optional)
# ---------------------------------------------------------------------------
def _stt_api_key() -> str:
"""Key for the STT provider; falls back to the backend key if unset."""
key = os.environ.get("STT_API_KEY", "").strip()
if key:
return key
return get_api_key()
def transcribe_audio(filepath: str, language: str = "hi") -> str:
"""Transcribe an audio file and return the text.
Uses the `ringglabs` STT SDK. Accepts standard WAV (any sample rate /
channels — the server reads the header), which is what Gradio's microphone
produces. The key comes from STT_API_KEY (or BACKEND_API_KEY as a fallback).
"""
if not filepath:
return ""
try:
from ringglabs.stt import Client
except ImportError as e:
raise BackendError(
"The 'ringglabs' package is required for voice input. "
"Add it to requirements.txt / pip install ringglabs."
) from e
with Client(api_key=_stt_api_key()) as client:
result = client.transcribe(filepath, language=language)
return (getattr(result, "transcription", "") or "").strip()
# ---------------------------------------------------------------------------
# Custom OpenAI-compatible backend
# ---------------------------------------------------------------------------
def chat_completion(
messages: list,
model: str,
endpoint_id: str,
temperature: float,
max_tokens: int,
tools: list | None = None,
) -> tuple:
"""Return (content, usage) from a non-streaming chat completion.
When `tools` is provided it is sent in the body so the model may call a
tool. If the model responds with an `end_call` tool call instead of plain
text, its `final_message` argument is returned as the content.
"""
payload = {
"messages": messages,
"model": model,
"endpoint_id": endpoint_id,
"temperature": float(temperature),
"max_tokens": int(max_tokens),
"stream": False,
}
if tools:
payload["tools"] = tools
headers = {"Content-Type": "application/json", "x-api-key": get_api_key()}
resp = requests.post(config.BASE_URL, headers=headers, json=payload, timeout=120)
if resp.status_code != 200:
raise BackendError(f"API returned HTTP {resp.status_code}: {resp.text[:500]}")
data = resp.json()
choices = data.get("choices") or []
content = ""
if choices:
message = choices[0].get("message") or {}
content = message.get("content") or ""
if not content:
content = _extract_end_call_message(message.get("tool_calls"))
usage = data.get("usage") or {}
return content, usage
def _extract_end_call_message(tool_calls) -> str:
"""Return the `final_message` from an `end_call` tool call, or "".
Accepts the non-streaming `tool_calls` list from a chat message. Other tool
calls (or malformed arguments) yield an empty string.
"""
for call in tool_calls or []:
fn = call.get("function") or {}
if fn.get("name") != "end_call":
continue
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
return ""
return (args.get("final_message") or "").strip()
return ""
def stream_chat(
messages: list,
model: str,
endpoint_id: str,
temperature: float,
max_tokens: int,
tools: list | None = None,
):
"""Yield response text deltas, then a final metrics sentinel.
When `tools` is provided it is sent in the body so the model may call a
tool. A streamed `end_call` tool call has no text content, so its
`final_message` argument (assembled from the streamed argument fragments)
is yielded as the reply once the stream ends.
Token counts come from a lightweight max_tokens=1 probe fired AFTER the
stream (so it never competes with streaming for GPU); completion tokens are
the streamed-piece count.
"""
payload = {
"messages": messages,
"model": model,
"endpoint_id": endpoint_id,
"temperature": float(temperature),
"max_tokens": int(max_tokens),
"stream": True,
}
if tools:
payload["tools"] = tools
headers = {"Content-Type": "application/json", "x-api-key": get_api_key()}
# Surface the exact request body (no secret header) for the debug panel.
yield {"__request__": True, "payload": payload}
t_start = time.monotonic()
piece_count = 0
ttfb = 0
tool_args = "" # concatenated `end_call` argument fragments
tool_name = None # name of the tool the model invoked, if any
content_buf = "" # raw text the model streamed (before any tool handling)
saw_end_call = False
with requests.post(
config.BASE_URL, headers=headers, json=payload, stream=True, timeout=120
) as resp:
if resp.status_code != 200:
raise BackendError(
f"API returned HTTP {resp.status_code}: {resp.text[:500]}"
)
for raw_line in resp.iter_lines(decode_unicode=True):
if not raw_line:
continue
if raw_line.startswith("data: "):
raw_line = raw_line[len("data: ") :]
if raw_line.strip() == "[DONE]":
break
try:
chunk = json.loads(raw_line)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") or []
if not choices:
continue
else:
ttfb = time.monotonic() - t_start
delta = choices[0].get("delta") or {}
piece = delta.get("content")
if piece:
piece_count += 1
content_buf += piece
yield piece
# An `end_call` tool call streams as `tool_calls` deltas whose
# `arguments` strings must be concatenated, then parsed at the end.
for call in delta.get("tool_calls") or []:
fn = call.get("function") or {}
if fn.get("name"):
tool_name = fn["name"]
if tool_name == "end_call":
saw_end_call = True
frag = fn.get("arguments")
if frag:
saw_end_call = True
tool_args += frag
# If the model ended the call via the tool, surface the goodbye line.
if saw_end_call:
try:
final_message = (json.loads(tool_args or "{}").get("final_message") or "").strip()
except json.JSONDecodeError:
final_message = ""
if final_message:
piece_count += 1
yield final_message
# Probe for token counts now that streaming is done.
try:
_, probe = chat_completion(
messages, model, endpoint_id, temperature, max_tokens=1, tools=tools
)
except Exception:
probe = {}
prompt_tokens = probe.get("prompt_tokens")
details = probe.get("prompt_tokens_details") or {}
cached_tokens = details.get("cached_tokens")
total = (prompt_tokens + piece_count) if prompt_tokens is not None else None
raw_response = {"content": content_buf or None}
if tool_name:
raw_response["tool_calls"] = [
{"function": {"name": tool_name, "arguments": tool_args}}
]
yield _empty_metrics(
prompt_tokens=prompt_tokens,
completion_tokens=piece_count,
total_tokens=total,
cached_tokens=cached_tokens,
latency_s=ttfb,
tool_called=tool_name,
raw_response=raw_response,
)
def _stream_custom(backend, messages, temperature, max_tokens):
tools = backend.get("tools", config.TOOLS)
yield from stream_chat(
messages,
backend["model"],
backend["endpoint_id"],
temperature,
max_tokens,
tools=tools,
)
# ---------------------------------------------------------------------------
# Azure OpenAI backend
# ---------------------------------------------------------------------------
def _azure_client(backend):
from openai import OpenAI
key = os.environ.get(backend.get("key_env", "AZURE_API_KEY"), "").strip()
if not key:
raise BackendError(
f"{backend.get('key_env', 'AZURE_API_KEY')} is not set. "
"Add it to .env or a Space Secret to use the Azure backend."
)
endpoint = (backend.get("endpoint") or "").rstrip("/")
if not endpoint:
raise BackendError("Azure endpoint is not configured (AZURE_ENDPOINT).")
return OpenAI(api_key=key, base_url=endpoint + "/openai/v1/")
def _stream_azure(backend, messages, temperature, max_tokens):
client = _azure_client(backend)
t_start = time.monotonic()
usage = None
content_buf = ""
request_payload = {
"model": backend["deployment"],
"messages": messages,
"temperature": float(temperature),
"max_completion_tokens": int(max_tokens),
"stream": True,
"stream_options": {"include_usage": True},
}
yield {"__request__": True, "payload": request_payload}
stream = client.chat.completions.create(**request_payload)
for chunk in stream:
if getattr(chunk, "usage", None):
usage = chunk.usage
choices = chunk.choices or []
if not choices:
continue
delta = choices[0].delta
piece = getattr(delta, "content", None) if delta else None
if piece:
content_buf += piece
yield piece
prompt_tokens = completion_tokens = total_tokens = cached_tokens = None
if usage is not None:
prompt_tokens = getattr(usage, "prompt_tokens", None)
completion_tokens = getattr(usage, "completion_tokens", None)
total_tokens = getattr(usage, "total_tokens", None)
details = getattr(usage, "prompt_tokens_details", None)
if details is not None:
cached_tokens = getattr(details, "cached_tokens", None)
yield _empty_metrics(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cached_tokens=cached_tokens,
latency_s=round(time.monotonic() - t_start, 3),
raw_response={"content": content_buf or None},
)
# ---------------------------------------------------------------------------
# Unified dispatch
# ---------------------------------------------------------------------------
def stream_backend(backend: dict, messages: list, temperature: float, max_tokens: int):
"""Backend-agnostic streaming generator.
Yields: str deltas, then a final {"__metrics__", ...} sentinel. Any backend
failure is caught and surfaced as a metrics sentinel with `error` set (the
worker never raises).
"""
btype = backend.get("type")
try:
if btype == "custom":
yield from _stream_custom(backend, messages, temperature, max_tokens)
elif btype == "azure":
yield from _stream_azure(backend, messages, temperature, max_tokens)
else:
yield _empty_metrics(error=f"Unknown backend type: {btype}")
except Exception as e: # noqa: BLE001 - surface per-backend errors in the UI
yield _empty_metrics(error=str(e))