chalchitra / backend /providers.py
ajit3259's picture
feat(space): wire Qwen2.5-VL-7B via hf_local provider (stage 2)
593e278
Raw
History Blame Contribute Delete
8.06 kB
"""Pluggable LLM providers for Chalchitra.
The whole point: develop against whatever model is "in front of us" (LM Studio
running Gemma locally, today) and swap to the Hugging Face / ZeroGPU model later
by changing config only β€” never the frontend, never the prompt, never the JSON
contract.
Both LM Studio and HF's TGI/vLLM serving expose the OpenAI chat-completions API,
so a single OpenAI-compatible provider covers both "in front of you" cases. The
hf_local provider is a stub for the eventual in-process transformers path on
ZeroGPU (with @spaces.GPU) if we choose to run the model inside the Space rather
than behind an OpenAI-compatible server.
There is no hosted/cloud API in the loop β€” the model answering is always the one
we are pointing at.
"""
from __future__ import annotations
import os
from typing import Any, Protocol
class Provider(Protocol):
"""Minimal contract every provider satisfies.
Given a system prompt and OpenAI-format content blocks (text + image_url),
return the model's raw text response. Parsing/validation is the oracle's job.
"""
name: str
model: str
def complete(
self,
system: str,
content: list[dict[str, Any]],
max_tokens: int,
temperature: float | None = None,
) -> str: ...
class OpenAICompatibleProvider:
"""Talks to any OpenAI chat-completions endpoint.
Covers LM Studio (local Gemma, dev) and HF TGI/vLLM (deploy) alike.
"""
def __init__(
self,
base_url: str,
model: str,
api_key: str = "not-needed",
temperature: float = 0.8,
timeout: float = 120.0,
) -> None:
# Imported lazily so the module loads even before deps are installed.
from openai import OpenAI
self.name = "openai_compatible"
self.base_url = base_url
self.model = model
self.temperature = temperature
# A bounded timeout so a down/slow model fails fast instead of hanging
# the request forever. max_retries=0 β€” we own retries in the oracle.
self._client = OpenAI(
base_url=base_url, api_key=api_key, timeout=timeout, max_retries=0
)
def complete(
self,
system: str,
content: list[dict[str, Any]],
max_tokens: int,
temperature: float | None = None,
) -> str:
resp = self._client.chat.completions.create(
model=self.model,
max_tokens=max_tokens,
temperature=self.temperature if temperature is None else temperature,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": content},
],
)
return resp.choices[0].message.content or ""
# ── ZeroGPU plumbing for the in-process model path ────────────────────────────
# `_gpu` applies @spaces.GPU only when running on a Space (where `spaces` is
# installed); locally it's a no-op so the same code imports without a GPU. The
# decorated `_generate` is defined at module level so it's registered for
# ZeroGPU's startup scan.
try:
import spaces as _spaces
def _gpu(fn):
return _spaces.GPU(duration=180)(fn)
except ImportError:
def _gpu(fn):
return fn
GPU_MAX_NEW_TOKENS = 2048
def _to_qwen_messages(system: str, content: list[dict[str, Any]]):
"""Translate our OpenAI-format content into Qwen2.5-VL chat messages.
image_url blocks (data URLs) become {"type":"image","image": <data url>},
which qwen-vl-utils decodes; text blocks pass through.
"""
user_content: list[dict[str, Any]] = []
for block in content:
if block.get("type") == "image_url":
user_content.append({"type": "image", "image": block["image_url"]["url"]})
elif block.get("type") == "text":
user_content.append({"type": "text", "text": block["text"]})
return [
{"role": "system", "content": system},
{"role": "user", "content": user_content},
]
@_gpu
def _generate(model, processor, messages, max_new_tokens: int, temperature: float) -> str:
"""The GPU-bound forward pass. Runs inside a ZeroGPU allocation on the Space."""
import torch
from qwen_vl_utils import process_vision_info
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(
text=[text],
images=image_inputs,
videos=video_inputs,
padding=True,
return_tensors="pt",
)
model.to("cuda")
inputs = inputs.to("cuda")
with torch.no_grad():
generated = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=temperature > 0,
temperature=temperature if temperature > 0 else None,
top_p=0.9,
)
trimmed = [out[len(inp):] for inp, out in zip(inputs.input_ids, generated)]
decoded = processor.batch_decode(
trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
)
return decoded[0] if decoded else ""
class HFLocalProvider:
"""Run Qwen2.5-VL in-process on ZeroGPU via transformers (the HF deploy path).
The Space loads the model itself and runs it on a ZeroGPU-allocated GPU. The
seam matches OpenAICompatibleProvider exactly, so app code never branches on
which path we took β€” only the env config changes. The model + processor load
lazily to CPU on first request; the forward pass moves to cuda inside the
`_gpu`-wrapped `_generate` (ZeroGPU only grants the GPU there).
"""
def __init__(self, model: str, temperature: float = 0.8, **_: Any) -> None:
self.name = "hf_local"
self.model = model
self.temperature = temperature
self._model = None
self._processor = None
def _ensure_loaded(self) -> None:
if self._model is not None:
return
import torch
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
self._processor = AutoProcessor.from_pretrained(self.model)
self._model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
self.model, torch_dtype=torch.bfloat16
)
def complete(
self,
system: str,
content: list[dict[str, Any]],
max_tokens: int,
temperature: float | None = None,
) -> str:
self._ensure_loaded()
messages = _to_qwen_messages(system, content)
temp = self.temperature if temperature is None else temperature
return _generate(self._model, self._processor, messages, max_tokens, temp)
def get_provider() -> Provider:
"""Build the configured provider from environment variables.
CHALCHITRA_PROVIDER openai_compatible (default) | hf_local
CHALCHITRA_BASE_URL default http://localhost:1234/v1 (LM Studio)
CHALCHITRA_MODEL default google/gemma-4-e4b
CHALCHITRA_API_KEY default "lm-studio" (LM Studio ignores it)
CHALCHITRA_TEMPERATURE default 0.8
"""
kind = os.environ.get("CHALCHITRA_PROVIDER", "openai_compatible").strip()
model = os.environ.get("CHALCHITRA_MODEL", "google/gemma-4-e4b").strip()
if kind == "openai_compatible":
return OpenAICompatibleProvider(
base_url=os.environ.get("CHALCHITRA_BASE_URL", "http://localhost:1234/v1").strip(),
model=model,
api_key=os.environ.get("CHALCHITRA_API_KEY", "lm-studio").strip(),
temperature=float(os.environ.get("CHALCHITRA_TEMPERATURE", "0.8")),
timeout=float(os.environ.get("CHALCHITRA_TIMEOUT", "120")),
)
if kind == "hf_local":
return HFLocalProvider(
model=model,
temperature=float(os.environ.get("CHALCHITRA_TEMPERATURE", "0.8")),
)
raise ValueError(
f"Unknown CHALCHITRA_PROVIDER={kind!r}. Use 'openai_compatible' or 'hf_local'."
)