qwen-coder-api / 1bomapp.py
Erinaldorodrigues's picture
Rename app.py to 1bomapp.py
a1d4796 verified
Raw
History Blame Contribute Delete
17.1 kB
"""Reliable ZeroGPU backend for the local OpenAI-compatible proxy."""
from __future__ import annotations
import json
import os
import time
import uuid
import traceback
from typing import Any
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
os.environ.setdefault("TRANSFORMERS_DISABLE_DEEPGEMM_LINEAR", "1")
import gradio as gr
import spaces
import torch
from fastapi import HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, ValidationError
from starlette.concurrency import run_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
StoppingCriteria,
StoppingCriteriaList,
)
from generation import (
gpu_duration_seconds,
head_tail_token_counts,
merge_eos_token_ids,
)
from openai_compat import (
analyze_tool_flow,
indexed_tool_calls,
normalize_tools,
resolve_tool_choice,
select_tools,
tool_choice_instruction,
tool_protocol_instruction,
tool_names,
)
from openclaude_compat import (
TOOL_PROTOCOL_MARKER,
add_system_instruction,
has_tool_protocol,
normalize_openclaude_messages,
)
from tool_calls import (
extract_tool_calls,
has_complete_tool_call,
)
from web_search import SearchUnavailable, search_web
# O Titã: Qwen2.5-Coder-32B nativamente quantizado em 4-bits (AWQ)
MODEL = os.getenv(
"MODEL",
os.getenv("MODEL_ID", "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"),
)
MAX_CONTEXT_TOKENS = int(os.getenv("MAX_CONTEXT_TOKENS", "16384"))
MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "2048"))
MAX_TOOL_CALL_TOKENS = int(os.getenv("MAX_TOOL_CALL_TOKENS", "2048"))
MAX_TEMPERATURE = float(os.getenv("MAX_TEMPERATURE", "0.2"))
PRESERVED_PREFIX_TOKENS = int(os.getenv("PRESERVED_PREFIX_TOKENS", "4096"))
tokenizer = AutoTokenizer.from_pretrained(MODEL)
# O ZeroGPU só anexa uma GPU real dentro de funções decoradas com
# @spaces.GPU; no escopo do módulo (startup) não existe CUDA de verdade,
# apenas uma emulação que aceita `.to("cuda")`/`device_map="auto"` como
# simples posicionamento de tensores. O carregamento deste modelo AWQ,
# porém, dispara o kernel Marlin (`awq_marlin_repack`) de forma síncrona
# dentro do próprio from_pretrained — isso é execução real de kernel CUDA,
# não posicionamento, e por isso não existe backend CPU para ele (era
# exatamente esse o erro do seu log). Por isso o carregamento precisa ser
# adiado para dentro de `gerar`, a única função com GPU real anexada.
model: AutoModelForCausalLM | None = None
def _ensure_model_loaded() -> None:
"""Carrega o modelo uma única vez, já dentro do contexto com GPU real."""
global model
if model is not None:
return
print(f"Loading {MODEL} on ZeroGPU (NATIVE AWQ)...", flush=True)
model = AutoModelForCausalLM.from_pretrained(
MODEL,
torch_dtype="auto",
device_map="auto",
low_cpu_mem_usage=True,
)
model.eval()
print(f"Model ready on {next(model.parameters()).device}", flush=True)
def _bounded_output_tokens(value: float) -> int:
try:
requested = int(value)
except (TypeError, ValueError):
requested = MAX_NEW_TOKENS
return max(1, min(requested, MAX_NEW_TOKENS))
# Buffer para cobrir a compilação JIT do kernel Marlin + carregamento dos
# pesos quando `gerar` cai num worker "frio" (sem o modelo em memória).
# É uma estimativa (baseada nos ~99s de compilação que aparecem no seu log);
# meça o cold start real do seu Space e ajuste. Confira também o teto de
# duração por chamada da sua tier em
# https://huggingface.co/docs/hub/spaces-zerogpu antes de subir esse valor —
# se o teto for menor que isso, a chamada falha com "illegal duration".
COLD_START_BUFFER_SECONDS = 180
def _gpu_duration(
messages_json: str,
__: float,
max_new_tokens: float,
*tool_arguments: object,
) -> int:
output_tokens = _bounded_output_tokens(max_new_tokens)
tool_characters = sum(
len(value) for value in tool_arguments if isinstance(value, str)
)
duration = gpu_duration_seconds(
len(messages_json) + tool_characters,
output_tokens,
MAX_CONTEXT_TOKENS,
)
return duration + COLD_START_BUFFER_SECONDS
def _tool_protocol_active(messages: list[object]) -> bool:
return any(
isinstance(message, dict)
and isinstance(message.get("content"), str)
and TOOL_PROTOCOL_MARKER in message["content"]
for message in messages
)
def _native_tools(raw_tools: object) -> list[dict[str, Any]]:
return normalize_tools(raw_tools)
class StopAfterToolCall(StoppingCriteria):
def __init__(self, prompt_length: int) -> None:
self.prompt_length = prompt_length
def __call__(self, input_ids, scores, **_: object):
completed = []
for sequence in input_ids:
generated = sequence[self.prompt_length :]
text = tokenizer.decode(generated, skip_special_tokens=False)
completed.append(has_complete_tool_call(text))
return torch.tensor(completed, dtype=torch.bool, device=input_ids.device)
@spaces.GPU(duration=_gpu_duration)
def gerar(
messages_json: str,
temperature: float,
max_new_tokens: float,
tools_json: str = "[]",
stop_after_first_tool: bool = True,
) -> str:
_ensure_model_loaded()
messages = json.loads(messages_json)
if not isinstance(messages, list):
raise ValueError("messages_json must contain a JSON list")
try:
tools = _native_tools(json.loads(tools_json))
except (TypeError, ValueError, json.JSONDecodeError):
tools = []
if not isinstance(tools, list):
tools = []
output_tokens = _bounded_output_tokens(max_new_tokens)
tool_mode = _tool_protocol_active(messages) or bool(tools)
template_kwargs: dict[str, Any] = {
"tokenize": False,
"add_generation_prompt": True,
}
if tools:
template_kwargs["tools"] = tools
try:
prompt = tokenizer.apply_chat_template(messages, **template_kwargs)
except Exception as template_error:
print(f"Jinja Template Warning: {template_error}. Applying fallback.", flush=True)
template_kwargs.pop("tools", None)
prompt = tokenizer.apply_chat_template(messages, **template_kwargs)
inputs = tokenizer(
prompt,
return_tensors="pt",
add_special_tokens=False,
truncation=False,
)
input_budget = max(1, MAX_CONTEXT_TOKENS - output_tokens)
input_length = inputs["input_ids"].shape[1]
if input_length > input_budget:
head_tokens, tail_tokens = head_tail_token_counts(
input_length,
input_budget,
PRESERVED_PREFIX_TOKENS,
)
for key, value in inputs.items():
if (
isinstance(value, torch.Tensor)
and value.ndim == 2
and value.shape[1] == input_length
):
parts = []
if head_tokens:
parts.append(value[:, :head_tokens])
if tail_tokens:
parts.append(value[:, -tail_tokens:])
inputs[key] = torch.cat(parts, dim=1)
inputs = inputs.to("cuda")
print(
f"Generation started: input_tokens={inputs['input_ids'].shape[1]} "
f"max_new_tokens={output_tokens} tool_mode={tool_mode}",
flush=True,
)
eos_token_ids = merge_eos_token_ids(
model.generation_config.eos_token_id,
tokenizer.eos_token_id,
)
generation_kwargs = {
"max_new_tokens": output_tokens,
"do_sample": float(temperature) > 0,
"pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
}
if eos_token_ids is not None:
generation_kwargs["eos_token_id"] = eos_token_ids
if generation_kwargs["do_sample"]:
generation_kwargs["temperature"] = max(0.01, float(temperature))
generation_kwargs["top_p"] = 0.8
generation_kwargs["top_k"] = 20
generation_kwargs["repetition_penalty"] = 1.05
if tool_mode and stop_after_first_tool:
generation_kwargs["stopping_criteria"] = StoppingCriteriaList(
[StopAfterToolCall(inputs["input_ids"].shape[1])]
)
with torch.inference_mode():
output = model.generate(**inputs, **generation_kwargs)
generated = output[0][inputs["input_ids"].shape[1] :]
response = tokenizer.decode(generated, skip_special_tokens=True).strip()
print(f"Generation completed: output_tokens={generated.shape[0]}", flush=True)
return response
class ChatCompletionRequest(BaseModel):
model: str = MODEL
messages: list[dict[str, Any]]
temperature: float = 0.2
max_tokens: int | None = None
max_completion_tokens: int | None = None
stream: bool = False
tools: list[dict[str, Any]] | None = None
tool_choice: Any = None
parallel_tool_calls: bool | None = None
def _completion_payload(request: ChatCompletionRequest) -> dict[str, Any]:
if request.model not in {
MODEL,
"qwen-coder",
"qwen3-coder",
"qwen2.5-coder-32b",
"qwen2.5-coder-14b",
}:
raise HTTPException(status_code=404, detail=f"Model not available: {request.model}")
already_adapted = has_tool_protocol(request.messages)
flow_state = analyze_tool_flow(request.messages, request.tools or [])
state_controls_choice = request.tool_choice is None or (
isinstance(request.tool_choice, str)
and request.tool_choice.casefold() == "auto"
)
effective_choice = resolve_tool_choice(request.tool_choice, flow_state)
try:
effective_tools, tool_mode = select_tools(
request.tools or [], effective_choice
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
instructions = [
instruction
for instruction in (
(
tool_protocol_instruction(effective_tools)
if effective_tools and not has_tool_protocol(request.messages)
else None
),
tool_choice_instruction(tool_mode, effective_tools),
(
flow_state.instruction
if state_controls_choice and not already_adapted
else None
),
)
if instruction
]
instruction = "\n\n".join(instructions) if instructions else None
max_tokens = request.max_completion_tokens or request.max_tokens or MAX_NEW_TOKENS
if effective_tools:
max_tokens = min(max_tokens, MAX_TOOL_CALL_TOKENS)
temperature = min(max(float(request.temperature), 0.01), MAX_TEMPERATURE)
try:
normalized_messages = (
[dict(message) for message in request.messages]
if already_adapted
else normalize_openclaude_messages(request.messages)
)
prompt_messages = add_system_instruction(
normalized_messages,
instruction,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
text = gerar(
json.dumps(prompt_messages),
temperature,
_bounded_output_tokens(max_tokens),
json.dumps(effective_tools, ensure_ascii=False),
request.parallel_tool_calls is not True,
)
if effective_tools:
tool_calls, content = extract_tool_calls(text, tool_names(effective_tools))
if request.parallel_tool_calls is False:
tool_calls = tool_calls[:1]
else:
tool_calls, content = [], text
message: dict[str, Any] = {"role": "assistant", "content": content or None}
finish_reason = "stop"
if tool_calls:
message["tool_calls"] = tool_calls
finish_reason = "tool_calls"
elif effective_tools and has_complete_tool_call(text):
finish_reason = "stop"
elif tool_mode in {"required", "forced"}:
finish_reason = "stop"
return {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": int(time.time()),
"model": MODEL,
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
def health() -> dict[str, str]:
return {"status": "ok", "model": MODEL}
def models() -> dict[str, Any]:
return {
"object": "list",
"data": [
{
"id": model_id,
"object": "model",
"owned_by": "Erinaldorodrigues",
"context_length": MAX_CONTEXT_TOKENS,
"max_input_tokens": MAX_CONTEXT_TOKENS,
"max_output_tokens": MAX_NEW_TOKENS,
}
for model_id in dict.fromkeys(("qwen2.5-coder-32b", MODEL))
],
}
def chat_completions(request: ChatCompletionRequest):
completion = _completion_payload(request)
if not request.stream:
return JSONResponse(content=completion)
choice = completion["choices"][0]
chunk_id = completion["id"]
def events():
first = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": completion["created"],
"model": MODEL,
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
}
yield f"data: {json.dumps(first)}\n\n"
delta: dict[str, Any] = {}
if choice["message"].get("content"):
delta["content"] = choice["message"]["content"]
if choice["message"].get("tool_calls"):
delta["tool_calls"] = indexed_tool_calls(
choice["message"]["tool_calls"]
)
body = {**first, "choices": [{"index": 0, "delta": delta, "finish_reason": None}]}
yield f"data: {json.dumps(body)}\n\n"
final = {**first, "choices": [{"index": 0, "delta": {}, "finish_reason": choice["finish_reason"]}]}
yield f"data: {json.dumps(final)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
events(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
demo = gr.Interface(
fn=gerar,
inputs=[
gr.Textbox(label="Messages JSON"),
gr.Number(value=0.2, label="Temperature"),
gr.Number(value=512, label="Max Tokens"),
],
outputs="text",
title="Qwen2.5-Coder-32B AWQ OpenAI-compatible ZeroGPU Backend",
)
class OpenAIRouteMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path.rstrip("/") or "/"
if path == "/health" and request.method == "GET":
return JSONResponse(health())
if path == "/web-search" and request.method == "GET":
query = request.query_params.get("q", "").strip()
if not query or len(query) > 500:
return JSONResponse(status_code=400, content={"error": "invalid query"})
try:
return JSONResponse(await run_in_threadpool(search_web, query))
except Exception:
return JSONResponse(status_code=500, content={"error": "search error"})
if path == "/v1/models" and request.method == "GET":
return JSONResponse(models())
if path == "/v1/chat/completions" and request.method == "POST":
try:
raw_request = await request.json()
parsed_request = ChatCompletionRequest(**raw_request)
except (json.JSONDecodeError, ValidationError, TypeError) as error:
return JSONResponse(status_code=400, content={"error": {"message": str(error)}})
try:
return chat_completions(parsed_request)
except HTTPException as error:
return JSONResponse(status_code=error.status_code, content={"error": {"message": error.detail}})
except Exception as error:
traceback.print_exc()
return JSONResponse(
status_code=500,
content={"error": {"message": f"internal Space error: {str(error)}"}}
)
return await call_next(request)
import gradio.routes as _groutes
_original_create_app = _groutes.App.create_app
def _create_app_with_openai_routes(*args, **kwargs):
created = _original_create_app(*args, **kwargs)
created.add_middleware(OpenAIRouteMiddleware)
return created
_groutes.App.create_app = staticmethod(_create_app_with_openai_routes)
demo.queue(default_concurrency_limit=1, max_size=8).launch(show_error=True, ssr_mode=False)