File size: 10,506 Bytes
66f749a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""

LLM client using Qwen model via HuggingFace Space (Ollama-compatible API)

with Ray actor pool for parallel processing.



Architecture:

- Each Ray actor makes HTTP requests to the Qwen Ollama endpoint

- Actor pool distributes requests with round-robin

- Robust retry with exponential backoff

"""
import os
import random
import time
import asyncio
import logging
import json
from pathlib import Path
from typing import Optional
from multiprocessing import cpu_count

import ray
import requests

# Load environment variables from .env file
from dotenv import load_dotenv
env_paths = [
    Path(__file__).parent.parent / ".env",
    Path(__file__).parent / ".env",
    Path.cwd() / ".env",
]
for env_path in env_paths:
    if env_path.exists():
        load_dotenv(env_path)
        break

logger = logging.getLogger(__name__)

# Default Qwen API configuration
DEFAULT_QWEN_API_URL = "https://vish85521-qwen.hf.space/api/generate"
DEFAULT_QWEN_MODEL = "qwen3.5:397b-cloud"


def _clean_env(name: str, default: str = "") -> str:
    """Read env var and trim surrounding whitespace/newlines."""
    return (os.getenv(name, default) or default).strip()


@ray.remote
class QwenActor:
    """

    Ray actor that sends requests to a Qwen model via Ollama-compatible HTTP API.



    Makes HTTP POST requests to the HuggingFace Space endpoint.

    Supports streaming NDJSON responses.

    """

    def __init__(self):
        """Initialize with API config from environment."""
        self._api_url = _clean_env("QWEN_API_URL", DEFAULT_QWEN_API_URL)
        self._model_name = _clean_env("QWEN_MODEL_NAME", DEFAULT_QWEN_MODEL)
        self._session = requests.Session()
        self._session.headers.update({"Content-Type": "application/json"})

        # Optional: HuggingFace token for private spaces
        hf_token = _clean_env("HF_TOKEN", "")
        if hf_token:
            self._session.headers["Authorization"] = f"Bearer {hf_token}"

        print(f"[QwenActor] Initialized — endpoint: {self._api_url}, model: {self._model_name}")

    def call(

        self,

        prompt: str,

        max_tokens: int = 200,

        temperature: float = 0.7,

        model_name: str = None,

        retries: int = 5,

    ) -> str:
        """

        Send a request to the Qwen Ollama API with retry and exponential backoff.



        Args:

            prompt: The prompt to send

            max_tokens: Maximum tokens to generate (passed as num_predict)

            temperature: Sampling temperature

            model_name: Override model name (uses env default if None)

            retries: Number of retries on failure



        Returns:

            Generated text response

        """
        model = model_name or self._model_name
        last_error = None

        for attempt in range(retries):
            try:
                print(f"[QwenActor] Attempt {attempt + 1}/{retries} calling {model}...")

                payload = {
                    "model": model,
                    "prompt": prompt,
                    "stream": True,
                    "format": "json",
                    "think": False
                }

                response = self._session.post(
                    self._api_url,
                    json=payload,
                    stream=True,
                    timeout=180,  # 3 minute timeout (free CPU is slow)
                )

                if response.status_code != 200:
                    raise Exception(
                        f"HTTP {response.status_code}: {response.text[:200]}"
                    )

                # Parse streaming NDJSON response
                full_response = ""
                full_thinking = ""
                for line in response.iter_lines():
                    if not line:
                        continue
                        
                    if isinstance(line, bytes):
                        line_str = line.decode('utf-8', errors='replace').strip()
                    else:
                        line_str = line.strip()
                        
                    if not line_str:
                        continue
                        
                    try:
                        data = json.loads(line_str)
                        if data.get("response"):
                            full_response += data["response"]
                        if data.get("thinking"):
                            full_thinking += data["thinking"]
                    except json.JSONDecodeError:
                        continue

                if full_response:
                    print(
                        f"[QwenActor] Success! Response: {len(full_response)} chars"
                    )
                    return full_response
                elif full_thinking and not full_response:
                     # Fallback in case it refused the JSON format and only gave thinking
                     print(f"[QwenActor] Warning: Got {len(full_thinking)} chars of thinking but no response string. Returning thinking instead.")
                     return full_thinking
                else:
                    print("[QwenActor] Empty response from model")
                    return ""

            except Exception as e:
                last_error = e
                wait_time = min(60, (2 ** attempt) + random.random() * 2)
                print(
                    f"[QwenActor] Error (attempt {attempt + 1}/{retries}), "
                    f"waiting {wait_time:.1f}s: {e}"
                )
                if attempt < retries - 1:
                    time.sleep(wait_time)
                else:
                    raise e

        # All retries exhausted
        if last_error:
            raise last_error
        return ""


class QwenLLM:
    """

    Manages a pool of QwenActor Ray actors for parallel LLM requests.



    Usage:

        llm = QwenLLM(num_actors=2)

        result = await llm.atext_request("Hello world")

        # or synchronously:

        result = llm.text_request("Hello world")

    """

    def __init__(self, num_actors: int = None):
        """

        Initialize the actor pool.



        Args:

            num_actors: Number of Ray actors to spawn.

                        Defaults to min(cpu_count(), 4).

        """
        if num_actors is None:
            num_actors = min(cpu_count(), 4)

        self._actors = [QwenActor.remote() for _ in range(num_actors)]
        self._next_index = 0
        logger.info(f"QwenLLM initialized with {num_actors} actors")

    def _get_next_actor(self):
        """Round-robin actor selection"""
        actor = self._actors[self._next_index % len(self._actors)]
        self._next_index += 1
        return actor

    async def atext_request(

        self,

        prompt: str,

        max_tokens: int = 200,

        temperature: float = 0.7,

        model_name: str = None,

        retries: int = 5,

    ) -> str:
        """

        Async request to the LLM actor pool.



        Selects an actor via round-robin and sends the request.



        Args:

            prompt: The prompt to send

            max_tokens: Maximum tokens to generate

            temperature: Sampling temperature

            model_name: Model override (uses env default if None)

            retries: Number of retries



        Returns:

            Generated text response

        """
        actor = self._get_next_actor()

        actor_idx = self._actors.index(actor) if actor in self._actors else '?'
        logger.info(f"LLM request dispatched to actor {actor_idx}")

        try:
            import time as _time
            _t0 = _time.time()
            result = await actor.call.remote(
                prompt=prompt,
                max_tokens=max_tokens,
                temperature=temperature,
                model_name=model_name,
                retries=retries,
            )
            _elapsed = _time.time() - _t0
            logger.info(f"LLM actor {actor_idx} responded in {_elapsed:.1f}s")
            return result
        except Exception as e:
            logger.error(f"LLM request failed on actor {actor_idx}: {e}")
            return ""

    def text_request(

        self,

        prompt: str,

        max_tokens: int = 200,

        temperature: float = 0.7,

        model_name: str = None,

        retries: int = 5,

    ) -> str:
        """

        Synchronous wrapper for atext_request.

        Uses ray.get() to block until the result is ready.

        """
        actor = self._get_next_actor()
        try:
            result = ray.get(
                actor.call.remote(
                    prompt=prompt,
                    max_tokens=max_tokens,
                    temperature=temperature,
                    model_name=model_name,
                    retries=retries,
                )
            )
            return result
        except Exception as e:
            logger.error(f"LLM request failed: {e}")
            return ""

    def shutdown(self):
        """Kill all actor handles"""
        for actor in self._actors:
            try:
                ray.kill(actor)
            except Exception:
                pass
        self._actors = []


# ---------------------------------------------------------------------------
# Backward-compatible convenience functions
# ---------------------------------------------------------------------------
_llm_pool: Optional[QwenLLM] = None


def get_llm_pool(num_actors: int = None) -> QwenLLM:
    """Get or create the global LLM actor pool"""
    global _llm_pool
    if _llm_pool is None:
        _llm_pool = QwenLLM(num_actors=num_actors)
    return _llm_pool


def shutdown_llm_pool():
    """Shutdown the global LLM pool"""
    global _llm_pool
    if _llm_pool is not None:
        _llm_pool.shutdown()
        _llm_pool = None


def call_llm_sync(

    prompt: str,

    max_tokens: int = 200,

    temperature: float = 0.7,

    model_name: str = None,

) -> str:
    """

    Legacy synchronous LLM call — delegates to the actor pool.

    """
    pool = get_llm_pool()
    return pool.text_request(
        prompt=prompt,
        max_tokens=max_tokens,
        temperature=temperature,
        model_name=model_name,
    )