Spaces:
Running on Zero
Running on Zero
| """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) | |
| 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) | |