Spooker commited on
Commit
4d2e96d
·
verified ·
1 Parent(s): 0b5dd49

Upload 8 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.log
7
+ .env
8
+ .venv/
9
+ venv/
10
+ node_modules/
Dockerfile ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1 \
7
+ PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
8
+ PORT=7860 \
9
+ HOST=0.0.0.0
10
+
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ xvfb \
13
+ curl \
14
+ ca-certificates \
15
+ fonts-liberation \
16
+ libasound2 \
17
+ libatk-bridge2.0-0 \
18
+ libatk1.0-0 \
19
+ libc6 \
20
+ libcairo2 \
21
+ libcups2 \
22
+ libdbus-1-3 \
23
+ libdrm2 \
24
+ libexpat1 \
25
+ libfontconfig1 \
26
+ libgbm1 \
27
+ libgcc1 \
28
+ libglib2.0-0 \
29
+ libgtk-3-0 \
30
+ libnspr4 \
31
+ libnss3 \
32
+ libpango-1.0-0 \
33
+ libpangocairo-1.0-0 \
34
+ libstdc++6 \
35
+ libx11-6 \
36
+ libx11-xcb1 \
37
+ libxcb1 \
38
+ libxcomposite1 \
39
+ libxdamage1 \
40
+ libxext6 \
41
+ libxfixes3 \
42
+ libxrandr2 \
43
+ libxrender1 \
44
+ libxshmfence1 \
45
+ libxkbcommon0 \
46
+ libxss1 \
47
+ libxtst6 \
48
+ wget \
49
+ && rm -rf /var/lib/apt/lists/*
50
+
51
+ RUN useradd -m -u 1000 user
52
+ USER user
53
+ ENV HOME=/home/user \
54
+ PATH=/home/user/.local/bin:$PATH
55
+ WORKDIR $HOME/app
56
+
57
+ COPY --chown=user requirements.txt ./requirements.txt
58
+ RUN pip install --upgrade pip && \
59
+ pip install -r requirements.txt && \
60
+ pip install pyvirtualdisplay && \
61
+ playwright install chromium
62
+
63
+ COPY --chown=user . $HOME/app
64
+
65
+ EXPOSE 7860
66
+ CMD ["python", "server.py"]
__pycache__/duck_client.cpython-313.pyc ADDED
Binary file (26.6 kB). View file
 
__pycache__/server.cpython-313.pyc ADDED
Binary file (17.5 kB). View file
 
config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "api_key": "sk-duck-ai",
3
+ "host": "0.0.0.0",
4
+ "port": 8787,
5
+ "default_model": "claude-haiku-4-5",
6
+ "assistant_name": null,
7
+ "system_prompt": null,
8
+ "web_search": true,
9
+ "proxy": null
10
+ }
duck_client.py ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+ import base64
4
+ import asyncio
5
+ import logging
6
+ import os
7
+ import sys
8
+ import time
9
+ from typing import AsyncIterator, Optional
10
+
11
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page, Route
12
+ from cryptography.hazmat.primitives.asymmetric import rsa, padding
13
+ from cryptography.hazmat.primitives import hashes
14
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
15
+ from cryptography.hazmat.backends import default_backend
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _virtual_display = None
20
+
21
+
22
+ def _ensure_display():
23
+ """On Linux without DISPLAY, start a virtual display via Xvfb."""
24
+ global _virtual_display
25
+ if _virtual_display is not None:
26
+ return
27
+ if sys.platform != "linux":
28
+ return # Windows/macOS always have a display
29
+ if os.environ.get("DISPLAY"):
30
+ return # Display already available
31
+ try:
32
+ from pyvirtualdisplay import Display
33
+ _virtual_display = Display(visible=False, size=(1280, 800))
34
+ _virtual_display.start()
35
+ logger.info(f"Started virtual display: {os.environ.get('DISPLAY')}")
36
+ except ImportError:
37
+ logger.warning(
38
+ "No DISPLAY and pyvirtualdisplay not installed. "
39
+ "Install with: pip install pyvirtualdisplay\n"
40
+ "Also install Xvfb: apt-get install xvfb"
41
+ )
42
+ raise RuntimeError(
43
+ "Headless server requires pyvirtualdisplay + xvfb. "
44
+ "Install: pip install pyvirtualdisplay && apt-get install xvfb"
45
+ )
46
+
47
+ DUCK_AI_BASE = "https://duck.ai"
48
+ CHAT_URL_PATTERN = "**/duckchat/v1/chat"
49
+ SEC_CH_UA = '"Not:A-Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"'
50
+ USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
51
+
52
+ DEFAULT_POOL_SIZE = 3
53
+
54
+
55
+ def _int_to_base64url(n: int, length: int = None) -> str:
56
+ b = n.to_bytes((n.bit_length() + 7) // 8, byteorder='big') if n > 0 else b'\x00'
57
+ if length and len(b) < length:
58
+ b = b'\x00' * (length - len(b)) + b
59
+ return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii')
60
+
61
+
62
+ def _rsa_public_key_to_jwk(public_key) -> dict:
63
+ pub_numbers = public_key.public_numbers()
64
+ n_bytes = (pub_numbers.n.bit_length() + 7) // 8
65
+ return {
66
+ "alg": "RSA-OAEP-256",
67
+ "e": _int_to_base64url(pub_numbers.e),
68
+ "ext": True,
69
+ "key_ops": ["encrypt"],
70
+ "kty": "RSA",
71
+ "n": _int_to_base64url(pub_numbers.n, n_bytes),
72
+ "use": "enc"
73
+ }
74
+
75
+
76
+ def _generate_rsa_keypair():
77
+ priv = rsa.generate_private_key(
78
+ public_exponent=65537, key_size=2048, backend=default_backend()
79
+ )
80
+ jwk = _rsa_public_key_to_jwk(priv.public_key())
81
+ return priv, jwk
82
+
83
+
84
+ def _decrypt_data(private_key, encrypted_b64: str) -> Optional[str]:
85
+ if not private_key:
86
+ return None
87
+ try:
88
+ raw = base64.b64decode(encrypted_b64)
89
+ enc_key = raw[:256]
90
+ rest = raw[256:]
91
+ aes_key = private_key.decrypt(
92
+ enc_key,
93
+ padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
94
+ algorithm=hashes.SHA256(), label=None)
95
+ )
96
+ iv, tag, ct = rest[:12], rest[-16:], rest[12:-16]
97
+ cipher = Cipher(algorithms.AES(aes_key), modes.GCM(iv, tag), backend=default_backend())
98
+ dec = cipher.decryptor()
99
+ return (dec.update(ct) + dec.finalize()).decode('utf-8')
100
+ except Exception as e:
101
+ logger.debug(f"Decryption failed: {e}")
102
+ return None
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # PageWorker: one browser tab that can handle one chat request at a time
107
+ # ---------------------------------------------------------------------------
108
+ class PageWorker:
109
+ def __init__(self, worker_id: int, context: BrowserContext):
110
+ self.id = worker_id
111
+ self._context = context
112
+ self.page: Optional[Page] = None
113
+ self.ready = False
114
+ self.request_count = 0
115
+
116
+ async def init(self):
117
+ """Create page, navigate to duck.ai, handle first-visit dialog."""
118
+ self.page = await self._context.new_page()
119
+ await self.page.goto(DUCK_AI_BASE, wait_until="domcontentloaded", timeout=60000)
120
+ await self.page.wait_for_timeout(3000)
121
+
122
+ for selector in [
123
+ "button:has-text('Get Started')",
124
+ "button:has-text('Continue')",
125
+ "button:has-text('I Agree')",
126
+ "button:has-text('Accept')",
127
+ ]:
128
+ try:
129
+ btn = self.page.locator(selector)
130
+ if await btn.count() > 0:
131
+ await btn.first.click(timeout=3000)
132
+ await self.page.wait_for_timeout(1000)
133
+ except:
134
+ pass
135
+
136
+ try:
137
+ await self.page.wait_for_selector("textarea:not([disabled])", timeout=15000)
138
+ except:
139
+ logger.warning(f"Worker {self.id}: textarea not enabled after 15s")
140
+
141
+ self.ready = True
142
+ logger.info(f"Worker {self.id} ready")
143
+
144
+ async def reinit(self):
145
+ """Recreate the page (e.g. after an error)."""
146
+ try:
147
+ if self.page:
148
+ await self.page.close()
149
+ except:
150
+ pass
151
+ self.page = None
152
+ self.ready = False
153
+ await self.init()
154
+
155
+ async def do_chat(self, desired_body: dict, private_key, public_key_jwk) -> str:
156
+ """
157
+ Send a chat request through the page's own UI flow with route interception.
158
+ Returns the raw SSE response body text.
159
+ """
160
+ response_data = {"body": None, "status": None, "error": None}
161
+
162
+ async def intercept_chat(route: Route):
163
+ try:
164
+ request = route.request
165
+ original_body = json.loads(request.post_data) if request.post_data else {}
166
+ durable = original_body.get("durableStream", {})
167
+ durable["publicKey"] = public_key_jwk
168
+ final_body = {**desired_body, "durableStream": durable}
169
+
170
+ resp = await route.fetch(post_data=json.dumps(final_body))
171
+ body_bytes = await resp.body()
172
+ response_data["status"] = resp.status
173
+ response_data["body"] = body_bytes.decode("utf-8", errors="replace")
174
+
175
+ await route.fulfill(
176
+ status=200,
177
+ content_type="text/event-stream",
178
+ body='data: {"action":"success"}\n\ndata: [DONE]\n\n'
179
+ )
180
+ except Exception as e:
181
+ response_data["error"] = str(e)
182
+ logger.error(f"Worker {self.id} intercept error: {e}")
183
+ try:
184
+ await route.fulfill(status=200, content_type="text/event-stream",
185
+ body='data: [DONE]\n\n')
186
+ except:
187
+ pass
188
+
189
+ # Click "New Chat" if available
190
+ try:
191
+ nc = self.page.locator("a:has-text('New Chat'), button:has-text('New Chat'), a[href='/']")
192
+ if await nc.count() > 0:
193
+ await nc.first.click(timeout=3000)
194
+ await self.page.wait_for_timeout(1000)
195
+ except:
196
+ pass
197
+
198
+ # Ensure textarea
199
+ try:
200
+ await self.page.wait_for_selector("textarea:not([disabled])", timeout=10000)
201
+ except:
202
+ await self.reinit()
203
+ await self.page.wait_for_selector("textarea:not([disabled])", timeout=10000)
204
+
205
+ await self.page.route(CHAT_URL_PATTERN, intercept_chat)
206
+
207
+ max_retries = 2
208
+ for attempt in range(max_retries + 1):
209
+ response_data = {"body": None, "status": None, "error": None}
210
+
211
+ try:
212
+ textarea = self.page.locator("textarea:not([disabled])")
213
+ await textarea.first.click(timeout=5000)
214
+ msgs = desired_body.get("messages", [])
215
+ last_msg = msgs[-1]["content"] if msgs else "Hi"
216
+ await textarea.first.fill(last_msg[:50])
217
+ await self.page.wait_for_timeout(200)
218
+ await textarea.first.press("Enter")
219
+
220
+ for _ in range(120):
221
+ await self.page.wait_for_timeout(500)
222
+ if response_data["body"] is not None or response_data["error"] is not None:
223
+ break
224
+ except Exception as e:
225
+ response_data["error"] = str(e)
226
+
227
+ try:
228
+ await self.page.unroute(CHAT_URL_PATTERN, intercept_chat)
229
+ except:
230
+ pass
231
+
232
+ if response_data["error"]:
233
+ logger.error(f"Worker {self.id} attempt {attempt+1}: {response_data['error']}")
234
+ if attempt < max_retries:
235
+ await self.reinit()
236
+ await self.page.route(CHAT_URL_PATTERN, intercept_chat)
237
+ continue
238
+ raise RuntimeError(f"Chat failed: {response_data['error']}")
239
+
240
+ status = response_data.get("status", 0)
241
+ body = response_data.get("body", "")
242
+
243
+ if status == 429 or (status == 418 and "ERR_BN_LIMIT" in body):
244
+ if attempt < max_retries:
245
+ logger.warning(f"Worker {self.id} rate limited, retrying...")
246
+ await asyncio.sleep(5)
247
+ await self.reinit()
248
+ await self.page.route(CHAT_URL_PATTERN, intercept_chat)
249
+ continue
250
+ raise RuntimeError(f"Rate limited: {body[:200]}")
251
+
252
+ if status != 200:
253
+ if attempt < max_retries:
254
+ logger.warning(f"Worker {self.id} error {status}, retrying...")
255
+ await self.reinit()
256
+ await self.page.route(CHAT_URL_PATTERN, intercept_chat)
257
+ continue
258
+ raise RuntimeError(f"Chat API error {status}: {body[:300]}")
259
+
260
+ self.request_count += 1
261
+ return body
262
+
263
+ raise RuntimeError("Exhausted retries")
264
+
265
+ async def close(self):
266
+ try:
267
+ if self.page:
268
+ await self.page.close()
269
+ except:
270
+ pass
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # DuckAIClient: manages browser + pool of PageWorkers
275
+ # ---------------------------------------------------------------------------
276
+ class DuckAIClient:
277
+ def __init__(self, proxy: str = None, model: str = "claude-haiku-4-5",
278
+ assistant_name: str = None, system_prompt: str = None,
279
+ pool_size: int = DEFAULT_POOL_SIZE):
280
+ self.model = model
281
+ self.assistant_name = assistant_name
282
+ self.system_prompt = system_prompt
283
+ self.proxy = proxy
284
+ self.pool_size = pool_size
285
+ self._playwright = None
286
+ self._browser: Optional[Browser] = None
287
+ self._context: Optional[BrowserContext] = None
288
+ self._pool: Optional[asyncio.Queue] = None
289
+ self._workers: list[PageWorker] = []
290
+ self._init_lock = asyncio.Lock()
291
+ self._ready = False
292
+
293
+ async def _ensure_ready(self):
294
+ if self._ready:
295
+ return
296
+ async with self._init_lock:
297
+ if self._ready:
298
+ return
299
+ _ensure_display()
300
+ logger.info(f"Launching browser (pool_size={self.pool_size})...")
301
+ self._playwright = await async_playwright().start()
302
+
303
+ launch_args = {
304
+ "headless": False,
305
+ "args": [
306
+ "--disable-blink-features=AutomationControlled",
307
+ "--window-position=-9999,-9999",
308
+ "--no-sandbox",
309
+ ],
310
+ }
311
+ self._browser = await self._playwright.chromium.launch(**launch_args)
312
+
313
+ ctx_args = {
314
+ "user_agent": USER_AGENT,
315
+ "viewport": {"width": 1280, "height": 800},
316
+ "locale": "en-US",
317
+ }
318
+ if self.proxy:
319
+ ctx_args["proxy"] = {"server": self.proxy}
320
+ self._context = await self._browser.new_context(**ctx_args)
321
+ await self._context.set_extra_http_headers({"sec-ch-ua": SEC_CH_UA})
322
+
323
+ # Create page workers
324
+ self._pool = asyncio.Queue()
325
+ self._workers = []
326
+ init_tasks = []
327
+ for i in range(self.pool_size):
328
+ w = PageWorker(i, self._context)
329
+ self._workers.append(w)
330
+ init_tasks.append(w.init())
331
+
332
+ # Initialize all workers in parallel
333
+ results = await asyncio.gather(*init_tasks, return_exceptions=True)
334
+ for i, res in enumerate(results):
335
+ if isinstance(res, Exception):
336
+ logger.error(f"Worker {i} init failed: {res}")
337
+ else:
338
+ self._pool.put_nowait(self._workers[i])
339
+
340
+ ready_count = self._pool.qsize()
341
+ logger.info(f"Browser ready: {ready_count}/{self.pool_size} workers")
342
+ if ready_count == 0:
343
+ raise RuntimeError("No workers initialized successfully")
344
+ self._ready = True
345
+
346
+ async def chat_stream(self, messages: list, web_search: bool = True,
347
+ custom_instructions: str = None,
348
+ assistant_name: str = None) -> AsyncIterator[dict]:
349
+ await self._ensure_ready()
350
+
351
+ # Build desired body
352
+ duck_messages = []
353
+ metadata = {}
354
+ name = assistant_name or self.assistant_name
355
+
356
+ if name or custom_instructions:
357
+ customization = {}
358
+ if name:
359
+ customization["assistantName"] = name
360
+ customization["shouldSeekClarity"] = False
361
+ if custom_instructions:
362
+ customization["customInstructions"] = custom_instructions
363
+ metadata["customization"] = customization
364
+
365
+ metadata["toolChoice"] = {
366
+ "WebSearch": web_search, "NewsSearch": False,
367
+ "VideosSearch": False, "LocalSearch": False, "WeatherForecast": False
368
+ }
369
+
370
+ for msg in messages:
371
+ role = msg.get("role", "user")
372
+ content = msg.get("content", "")
373
+ if role == "system":
374
+ metadata.setdefault("customization", {})["customInstructions"] = content
375
+ continue
376
+ duck_messages.append({
377
+ "role": "assistant" if role == "assistant" else "user",
378
+ "content": content
379
+ })
380
+
381
+ if not duck_messages:
382
+ duck_messages = [{"role": "user", "content": "Hello"}]
383
+
384
+ desired_body = {
385
+ "model": self.model,
386
+ "messages": duck_messages,
387
+ "canUseTools": web_search,
388
+ "canUseApproxLocation": None,
389
+ }
390
+ if metadata:
391
+ desired_body["metadata"] = metadata
392
+
393
+ # Generate per-request RSA keypair
394
+ private_key, public_key_jwk = _generate_rsa_keypair()
395
+
396
+ # Acquire a worker from the pool (blocks if all busy)
397
+ worker: PageWorker = await asyncio.wait_for(self._pool.get(), timeout=120)
398
+ try:
399
+ t0 = time.time()
400
+ raw_body = await worker.do_chat(desired_body, private_key, public_key_jwk)
401
+ elapsed = time.time() - t0
402
+ logger.info(f"Worker {worker.id} completed in {elapsed:.1f}s "
403
+ f"(total: {worker.request_count})")
404
+ except Exception as e:
405
+ # Return worker even on failure
406
+ self._pool.put_nowait(worker)
407
+ raise
408
+ else:
409
+ # Return worker to pool
410
+ self._pool.put_nowait(worker)
411
+
412
+ # Parse SSE response
413
+ async for event in self._parse_sse_text(raw_body, private_key):
414
+ yield event
415
+
416
+ async def _parse_sse_text(self, text: str, private_key) -> AsyncIterator[dict]:
417
+ for line in text.split("\n"):
418
+ line = line.strip()
419
+ if not line or line.startswith(":") or line.startswith("event:"):
420
+ continue
421
+ if not line.startswith("data: "):
422
+ continue
423
+ data = line[6:]
424
+ if data == "[DONE]":
425
+ yield {"type": "done"}
426
+ return
427
+
428
+ try:
429
+ parsed = json.loads(data)
430
+ except json.JSONDecodeError:
431
+ dec = _decrypt_data(private_key, data)
432
+ if dec:
433
+ try:
434
+ parsed = json.loads(dec)
435
+ except json.JSONDecodeError:
436
+ yield {"type": "text", "data": dec}
437
+ continue
438
+ else:
439
+ continue
440
+
441
+ if "encryptedData" in parsed:
442
+ dec = _decrypt_data(private_key, parsed["encryptedData"])
443
+ if dec:
444
+ try:
445
+ inner = json.loads(dec)
446
+ yield {"type": "message", "data": inner}
447
+ except json.JSONDecodeError:
448
+ yield {"type": "text", "data": dec}
449
+ continue
450
+
451
+ action = parsed.get("action")
452
+ role = parsed.get("role", "")
453
+
454
+ # Tool invocation events (WebSearch, etc.)
455
+ if role == "tool-invocation":
456
+ state = parsed.get("state", "")
457
+ if state == "call":
458
+ yield {"type": "search_begin", "data": parsed}
459
+ elif state == "result":
460
+ yield {"type": "search_results", "data": parsed}
461
+ else:
462
+ yield {"type": "event", "data": parsed}
463
+ continue
464
+
465
+ # Source/citation events from web search
466
+ if role == "source":
467
+ source = parsed.get("source", {})
468
+ yield {"type": "search_source", "data": source}
469
+ continue
470
+
471
+ if action in ("search_begin", "search_end"):
472
+ yield {"type": action, "data": parsed}
473
+ elif action in ("search_result", "search_results"):
474
+ yield {"type": "search_results", "data": parsed}
475
+ elif "message" in parsed:
476
+ msg = parsed["message"]
477
+ if isinstance(msg, str):
478
+ yield {"type": "text", "data": msg}
479
+ else:
480
+ yield {"type": "event", "data": parsed}
481
+ elif "data" in parsed:
482
+ val = parsed["data"]
483
+ if isinstance(val, str):
484
+ dec = _decrypt_data(private_key, val)
485
+ yield {"type": "text", "data": dec if dec else val}
486
+ else:
487
+ yield {"type": "event", "data": parsed}
488
+ else:
489
+ yield {"type": "event", "data": parsed}
490
+
491
+ def pool_status(self) -> dict:
492
+ """Return current pool utilization stats."""
493
+ if not self._ready:
494
+ return {"ready": False}
495
+ total = len(self._workers)
496
+ available = self._pool.qsize() if self._pool else 0
497
+ return {
498
+ "ready": True,
499
+ "total_workers": total,
500
+ "available_workers": available,
501
+ "busy_workers": total - available,
502
+ "worker_stats": [
503
+ {"id": w.id, "requests": w.request_count, "ready": w.ready}
504
+ for w in self._workers
505
+ ],
506
+ }
507
+
508
+ async def close(self):
509
+ for w in self._workers:
510
+ await w.close()
511
+ self._workers.clear()
512
+ try:
513
+ if self._browser:
514
+ await self._browser.close()
515
+ if self._playwright:
516
+ await self._playwright.stop()
517
+ except:
518
+ pass
519
+ self._ready = False
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn[standard]>=0.24.0
3
+ playwright>=1.40.0
4
+ cryptography>=41.0.0
5
+ # For headless Linux servers (optional, not needed on Windows/macOS):
6
+ # pyvirtualdisplay>=3.0
7
+ # Also install system package: apt-get install xvfb
server.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import time
4
+ import uuid
5
+ import logging
6
+ import asyncio
7
+ from typing import Optional
8
+ from contextlib import asynccontextmanager
9
+
10
+ from fastapi import FastAPI, Request, HTTPException
11
+ from fastapi.responses import StreamingResponse, JSONResponse
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+
14
+ from duck_client import DuckAIClient
15
+
16
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
17
+ logger = logging.getLogger(__name__)
18
+
19
+ CONFIG_FILE = os.environ.get("CONFIG_FILE", "config.json")
20
+
21
+
22
+ def load_config() -> dict:
23
+ try:
24
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
25
+ return json.load(f)
26
+ except FileNotFoundError:
27
+ return {}
28
+
29
+
30
+ config = load_config()
31
+
32
+
33
+ def env_or_config(env_name: str, config_key: str, default=None, cast=None):
34
+ value = os.environ.get(env_name)
35
+ if value is None:
36
+ value = config.get(config_key, default)
37
+ if value is None:
38
+ return None
39
+ if cast is bool:
40
+ if isinstance(value, bool):
41
+ return value
42
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
43
+ if cast is int:
44
+ return int(value)
45
+ if cast:
46
+ return cast(value)
47
+ return value
48
+
49
+
50
+ API_KEY = env_or_config("API_KEY", "api_key", "sk-duck-ai")
51
+ PROXY = env_or_config("PROXY", "proxy", None)
52
+ HOST = env_or_config("HOST", "host", "0.0.0.0")
53
+ PORT = env_or_config("PORT", "port", 7860, cast=int)
54
+ DEFAULT_MODEL = env_or_config("DEFAULT_MODEL", "default_model", "claude-haiku-4-5")
55
+ ASSISTANT_NAME = env_or_config("ASSISTANT_NAME", "assistant_name", None)
56
+ SYSTEM_PROMPT = env_or_config("SYSTEM_PROMPT", "system_prompt", None)
57
+ WEB_SEARCH = env_or_config("WEB_SEARCH", "web_search", True, cast=bool)
58
+ POOL_SIZE = env_or_config("POOL_SIZE", "pool_size", 2, cast=int)
59
+
60
+ # Single shared client with page pool
61
+ _shared_client: Optional[DuckAIClient] = None
62
+ _client_lock = asyncio.Lock()
63
+
64
+
65
+ async def get_client() -> DuckAIClient:
66
+ global _shared_client
67
+ async with _client_lock:
68
+ if _shared_client is None:
69
+ _shared_client = DuckAIClient(
70
+ proxy=PROXY,
71
+ model=DEFAULT_MODEL,
72
+ assistant_name=ASSISTANT_NAME,
73
+ system_prompt=SYSTEM_PROMPT,
74
+ pool_size=POOL_SIZE,
75
+ )
76
+ return _shared_client
77
+
78
+
79
+ async def return_client(c: DuckAIClient):
80
+ return None
81
+
82
+
83
+ @asynccontextmanager
84
+ async def lifespan(app: FastAPI):
85
+ logger.info("Duck.ai 2API server starting on %s:%s", HOST, PORT)
86
+ logger.info("Default model: %s", DEFAULT_MODEL)
87
+ logger.info("Web search: %s", WEB_SEARCH)
88
+ logger.info("Pool size: %s", POOL_SIZE)
89
+ logger.info("Proxy: %s", PROXY or "None")
90
+ yield
91
+ global _shared_client
92
+ if _shared_client:
93
+ await _shared_client.close()
94
+ _shared_client = None
95
+ logger.info("Server shutdown complete")
96
+
97
+
98
+ app = FastAPI(title="Duck.ai 2API", lifespan=lifespan)
99
+
100
+ app.add_middleware(
101
+ CORSMiddleware,
102
+ allow_origins=["*"],
103
+ allow_credentials=True,
104
+ allow_methods=["*"],
105
+ allow_headers=["*"],
106
+ )
107
+
108
+
109
+ def verify_auth(request: Request):
110
+ if not API_KEY:
111
+ return
112
+ auth = request.headers.get("Authorization", "")
113
+ if auth.startswith("Bearer "):
114
+ token = auth[7:]
115
+ else:
116
+ token = auth
117
+ if token != API_KEY:
118
+ raise HTTPException(status_code=401, detail="Invalid API key")
119
+
120
+
121
+ MODEL_MAP = {
122
+ "claude-haiku-4-5": "claude-haiku-4-5",
123
+ "claude-3-haiku": "claude-haiku-4-5",
124
+ "claude-3-5-haiku": "claude-haiku-4-5",
125
+ "claude-3-haiku-20240307": "claude-haiku-4-5",
126
+ "gpt-4o-mini": "gpt-4o-mini",
127
+ "gpt-4o": "gpt-4o-mini",
128
+ "gpt-3.5-turbo": "gpt-4o-mini",
129
+ "llama-3.3-70b": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
130
+ "mixtral-8x7b": "mistralai/Mixtral-8x7B-Instruct-v0.1",
131
+ "o3-mini": "o3-mini",
132
+ }
133
+
134
+
135
+ def map_model(model: str) -> str:
136
+ return MODEL_MAP.get(model, DEFAULT_MODEL)
137
+
138
+
139
+ @app.get("/v1/models")
140
+ @app.get("/models")
141
+ async def list_models(request: Request):
142
+ verify_auth(request)
143
+ models = [
144
+ {"id": "claude-haiku-4-5", "object": "model", "owned_by": "anthropic"},
145
+ {"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"},
146
+ {"id": "o3-mini", "object": "model", "owned_by": "openai"},
147
+ {"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "object": "model", "owned_by": "meta"},
148
+ {"id": "mistralai/Mixtral-8x7B-Instruct-v0.1", "object": "model", "owned_by": "mistral"},
149
+ ]
150
+ return {"object": "list", "data": models}
151
+
152
+
153
+ @app.post("/v1/chat/completions")
154
+ @app.post("/chat/completions")
155
+ async def chat_completions(request: Request):
156
+ verify_auth(request)
157
+
158
+ try:
159
+ body = await request.json()
160
+ except Exception:
161
+ raise HTTPException(status_code=400, detail="Invalid JSON body")
162
+
163
+ messages = body.get("messages", [])
164
+ if not messages:
165
+ raise HTTPException(status_code=400, detail="messages is required")
166
+
167
+ req_model = body.get("model", DEFAULT_MODEL)
168
+ duck_model = map_model(req_model)
169
+ stream = body.get("stream", False)
170
+ web_search = body.get("web_search", WEB_SEARCH)
171
+
172
+ custom_instructions = None
173
+ assistant_name = ASSISTANT_NAME
174
+ for msg in messages:
175
+ if msg.get("role") == "system":
176
+ custom_instructions = msg.get("content", "")
177
+ break
178
+
179
+ completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
180
+ created = int(time.time())
181
+
182
+ client = await get_client()
183
+ client.model = duck_model
184
+
185
+ if stream:
186
+ return StreamingResponse(
187
+ _stream_response(
188
+ client,
189
+ messages,
190
+ web_search,
191
+ custom_instructions,
192
+ assistant_name,
193
+ completion_id,
194
+ created,
195
+ req_model,
196
+ ),
197
+ media_type="text/event-stream",
198
+ headers={
199
+ "Cache-Control": "no-cache",
200
+ "Connection": "keep-alive",
201
+ "X-Accel-Buffering": "no",
202
+ },
203
+ )
204
+ return await _non_stream_response(
205
+ client,
206
+ messages,
207
+ web_search,
208
+ custom_instructions,
209
+ assistant_name,
210
+ completion_id,
211
+ created,
212
+ req_model,
213
+ )
214
+
215
+
216
+ async def _stream_response(client: DuckAIClient, messages: list,
217
+ web_search: bool, custom_instructions: str,
218
+ assistant_name: str, completion_id: str,
219
+ created: int, model: str):
220
+ try:
221
+ first_chunk = {
222
+ "id": completion_id,
223
+ "object": "chat.completion.chunk",
224
+ "created": created,
225
+ "model": model,
226
+ "choices": [{
227
+ "index": 0,
228
+ "delta": {"role": "assistant", "content": ""},
229
+ "finish_reason": None,
230
+ }],
231
+ }
232
+ yield f"data: {json.dumps(first_chunk)}\n\n"
233
+
234
+ search_sources = []
235
+ async for event in client.chat_stream(
236
+ messages=messages,
237
+ web_search=web_search,
238
+ custom_instructions=custom_instructions,
239
+ assistant_name=assistant_name,
240
+ ):
241
+ etype = event.get("type")
242
+
243
+ if etype == "text":
244
+ text = event.get("data", "")
245
+ if text and isinstance(text, str):
246
+ chunk = {
247
+ "id": completion_id,
248
+ "object": "chat.completion.chunk",
249
+ "created": created,
250
+ "model": model,
251
+ "choices": [{
252
+ "index": 0,
253
+ "delta": {"content": text},
254
+ "finish_reason": None,
255
+ }],
256
+ }
257
+ yield f"data: {json.dumps(chunk)}\n\n"
258
+ elif etype == "message":
259
+ data = event.get("data", {})
260
+ text = ""
261
+ if isinstance(data, dict):
262
+ text = data.get("message", data.get("content", ""))
263
+ elif isinstance(data, str):
264
+ text = data
265
+ if text and isinstance(text, str):
266
+ chunk = {
267
+ "id": completion_id,
268
+ "object": "chat.completion.chunk",
269
+ "created": created,
270
+ "model": model,
271
+ "choices": [{
272
+ "index": 0,
273
+ "delta": {"content": text},
274
+ "finish_reason": None,
275
+ }],
276
+ }
277
+ yield f"data: {json.dumps(chunk)}\n\n"
278
+ elif etype == "search_source":
279
+ src = event.get("data", {})
280
+ if isinstance(src, dict) and src.get("url"):
281
+ search_sources.append(src)
282
+ elif etype in ("search_begin", "search_results", "search_end"):
283
+ pass
284
+ elif etype == "done":
285
+ break
286
+ elif etype == "event":
287
+ data = event.get("data", {})
288
+ if isinstance(data, dict):
289
+ msg = data.get("message", data.get("content", ""))
290
+ if msg and isinstance(msg, str):
291
+ chunk = {
292
+ "id": completion_id,
293
+ "object": "chat.completion.chunk",
294
+ "created": created,
295
+ "model": model,
296
+ "choices": [{
297
+ "index": 0,
298
+ "delta": {"content": msg},
299
+ "finish_reason": None,
300
+ }],
301
+ }
302
+ yield f"data: {json.dumps(chunk)}\n\n"
303
+
304
+ if search_sources:
305
+ refs = "\n\n---\n**搜索结果:**\n"
306
+ for i, src in enumerate(search_sources[:8], 1):
307
+ title = src.get("title", "")
308
+ url = src.get("url", "")
309
+ site = src.get("site", "")
310
+ favicon = f"https://www.google.com/s2/favicons?domain={site}&sz=32" if site else ""
311
+ if title and url:
312
+ icon = f"![favicon]({favicon}) " if favicon else ""
313
+ refs += f"{i}. {icon}[{title}]({url}) - {site}\n"
314
+ chunk = {
315
+ "id": completion_id,
316
+ "object": "chat.completion.chunk",
317
+ "created": created,
318
+ "model": model,
319
+ "choices": [{
320
+ "index": 0,
321
+ "delta": {"content": refs},
322
+ "finish_reason": None,
323
+ }],
324
+ }
325
+ yield f"data: {json.dumps(chunk)}\n\n"
326
+
327
+ final_chunk = {
328
+ "id": completion_id,
329
+ "object": "chat.completion.chunk",
330
+ "created": created,
331
+ "model": model,
332
+ "choices": [{
333
+ "index": 0,
334
+ "delta": {},
335
+ "finish_reason": "stop",
336
+ }],
337
+ }
338
+ yield f"data: {json.dumps(final_chunk)}\n\n"
339
+ yield "data: [DONE]\n\n"
340
+ except Exception as e:
341
+ logger.error("Stream error: %s", e, exc_info=True)
342
+ error_chunk = {
343
+ "id": completion_id,
344
+ "object": "chat.completion.chunk",
345
+ "created": created,
346
+ "model": model,
347
+ "choices": [{
348
+ "index": 0,
349
+ "delta": {"content": f"\n\n[Error: {str(e)}]"},
350
+ "finish_reason": "stop",
351
+ }],
352
+ }
353
+ yield f"data: {json.dumps(error_chunk)}\n\n"
354
+ yield "data: [DONE]\n\n"
355
+ finally:
356
+ await return_client(client)
357
+
358
+
359
+ async def _non_stream_response(client: DuckAIClient, messages: list,
360
+ web_search: bool, custom_instructions: str,
361
+ assistant_name: str, completion_id: str,
362
+ created: int, model: str):
363
+ full_content = ""
364
+ search_sources = []
365
+ try:
366
+ async for event in client.chat_stream(
367
+ messages=messages,
368
+ web_search=web_search,
369
+ custom_instructions=custom_instructions,
370
+ assistant_name=assistant_name,
371
+ ):
372
+ etype = event.get("type")
373
+ if etype == "text":
374
+ val = event.get("data", "")
375
+ if isinstance(val, str):
376
+ full_content += val
377
+ elif etype == "message":
378
+ data = event.get("data", {})
379
+ if isinstance(data, dict):
380
+ msg = data.get("message", data.get("content", ""))
381
+ if isinstance(msg, str):
382
+ full_content += msg
383
+ elif isinstance(data, str):
384
+ full_content += data
385
+ elif etype == "search_source":
386
+ src = event.get("data", {})
387
+ if isinstance(src, dict) and src.get("url"):
388
+ search_sources.append(src)
389
+ elif etype == "event":
390
+ data = event.get("data", {})
391
+ if isinstance(data, dict):
392
+ msg = data.get("message", data.get("content", ""))
393
+ if isinstance(msg, str):
394
+ full_content += msg
395
+ elif etype == "done":
396
+ break
397
+ except Exception as e:
398
+ logger.error("Chat error: %s", e, exc_info=True)
399
+ raise HTTPException(status_code=500, detail=str(e))
400
+ finally:
401
+ await return_client(client)
402
+
403
+ if search_sources:
404
+ refs = "\n\n---\n**搜索结果:**\n"
405
+ for i, src in enumerate(search_sources[:8], 1):
406
+ title = src.get("title", "")
407
+ url = src.get("url", "")
408
+ site = src.get("site", "")
409
+ favicon = f"https://www.google.com/s2/favicons?domain={site}&sz=32" if site else ""
410
+ if title and url:
411
+ icon = f"![favicon]({favicon}) " if favicon else ""
412
+ refs += f"{i}. {icon}[{title}]({url}) - {site}\n"
413
+ full_content += refs
414
+
415
+ prompt_tokens = sum(len(m.get("content", "")) for m in messages) // 4
416
+ completion_tokens = len(full_content) // 4
417
+
418
+ return JSONResponse({
419
+ "id": completion_id,
420
+ "object": "chat.completion",
421
+ "created": created,
422
+ "model": model,
423
+ "choices": [{
424
+ "index": 0,
425
+ "message": {"role": "assistant", "content": full_content},
426
+ "finish_reason": "stop",
427
+ }],
428
+ "usage": {
429
+ "prompt_tokens": prompt_tokens,
430
+ "completion_tokens": completion_tokens,
431
+ "total_tokens": prompt_tokens + completion_tokens,
432
+ },
433
+ })
434
+
435
+
436
+ @app.get("/")
437
+ async def root():
438
+ return {
439
+ "service": "Duck.ai 2API",
440
+ "status": "running",
441
+ "model": DEFAULT_MODEL,
442
+ "docs": "/docs",
443
+ "health": "/health",
444
+ }
445
+
446
+
447
+ @app.get("/health")
448
+ async def health():
449
+ client = None
450
+ try:
451
+ client = await get_client()
452
+ return {
453
+ "status": "ok",
454
+ "model": DEFAULT_MODEL,
455
+ "pool": client.pool_status(),
456
+ }
457
+ except Exception as e:
458
+ return JSONResponse(
459
+ status_code=503,
460
+ content={"status": "error", "detail": str(e)},
461
+ )
462
+
463
+
464
+ if __name__ == "__main__":
465
+ import uvicorn
466
+
467
+ uvicorn.run("server:app", host=HOST, port=PORT, reload=False)