infinityonline commited on
Commit
d649e09
·
verified ·
1 Parent(s): a45f112

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +52 -0
  2. README.md +8 -5
  3. main.py +422 -0
  4. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # ── HuggingFace يحتاج user بـ UID=1000
4
+ RUN useradd -m -u 1000 user
5
+
6
+ ENV HOME=/home/user \
7
+ PATH=/home/user/.local/bin:$PATH \
8
+ PYTHONUNBUFFERED=1 \
9
+ PLAYWRIGHT_BROWSERS_PATH=/home/user/.cache/ms-playwright
10
+
11
+ WORKDIR /home/user/app
12
+
13
+ # ── System libs (نفس الأصلي)
14
+ RUN apt-get update && apt-get install -y \
15
+ wget gnupg ca-certificates curl \
16
+ libnss3 libnspr4 \
17
+ libatk1.0-0 libatk-bridge2.0-0 \
18
+ libcups2 libdrm2 libxkbcommon0 \
19
+ libxcomposite1 libxdamage1 libxext6 \
20
+ libxfixes3 libxrandr2 libgbm1 \
21
+ libpango-1.0-0 libcairo2 libasound2 \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ # ── Install Python packages
25
+ COPY --chown=user requirements.txt .
26
+ RUN pip install --no-cache-dir -r requirements.txt
27
+
28
+ # ── Install Google Chrome (عشان channel="chrome" يشتغل)
29
+ RUN mkdir -p /etc/apt/keyrings \
30
+ && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub \
31
+ | gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg \
32
+ && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] \
33
+ http://dl.google.com/linux/chrome/deb/ stable main" \
34
+ > /etc/apt/sources.list.d/google-chrome.list \
35
+ && apt-get update && apt-get install -y google-chrome-stable \
36
+ && rm -rf /var/lib/apt/lists/*
37
+
38
+ # ── Install Playwright Chromium (fallback)
39
+ RUN playwright install chromium
40
+
41
+ # ── Copy project files
42
+ COPY --chown=user . .
43
+
44
+ USER user
45
+
46
+ # ── HuggingFace port = 7860
47
+ EXPOSE 7860
48
+
49
+ HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=3 \
50
+ CMD curl -f http://localhost:7860/health || exit 1
51
+
52
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Api
3
- emoji: 🏆
4
- colorFrom: indigo
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
  ---
2
+ title: MSE AI API
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
  ---
11
 
12
+ # MSE AI API
13
+ OpenAI-compatible API via FastAPI + Playwright + Chrome.
main.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import time
4
+ import asyncio
5
+ import threading
6
+ import json
7
+ import re
8
+ from typing import Optional
9
+ from fastapi import FastAPI, Header, HTTPException, Request
10
+ from fastapi.responses import JSONResponse
11
+
12
+
13
+ API_SECRET_KEY = os.getenv("API_SECRET_KEY", "change-secret-key-2026")
14
+
15
+
16
+ class AsyncBrowserThread(threading.Thread):
17
+ def __init__(self):
18
+ super().__init__(daemon=True)
19
+ self.loop = asyncio.new_event_loop()
20
+ self.ready_event = threading.Event()
21
+ self.browser = None
22
+ self.playwright = None
23
+
24
+ def run(self):
25
+ asyncio.set_event_loop(self.loop)
26
+ self.loop.run_until_complete(self._start_browser())
27
+ self.ready_event.set()
28
+ print("[LITE-SERVER] Browser is ready!")
29
+ self.loop.run_forever()
30
+
31
+ async def _start_browser(self):
32
+ from playwright.async_api import async_playwright
33
+ print("[LITE-SERVER] Starting Chrome...")
34
+ self.playwright = await async_playwright().start()
35
+ self.browser = await self.playwright.chromium.launch(
36
+ headless=True,
37
+ channel="chrome",
38
+ args=[
39
+ '--disable-blink-features=AutomationControlled',
40
+ '--no-sandbox',
41
+ '--disable-gpu',
42
+ '--disable-dev-shm-usage',
43
+ '--disable-setuid-sandbox',
44
+ '--single-process',
45
+ '--no-zygote',
46
+ ]
47
+ )
48
+ print("[LITE-SERVER] Chrome launched!")
49
+
50
+ async def _talk_to_chatgpt(self, prompt: str):
51
+ context = await self.browser.new_context(
52
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
53
+ viewport={'width': 1920, 'height': 1080}
54
+ )
55
+
56
+ await context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
57
+
58
+ page = await context.new_page()
59
+
60
+ try:
61
+ page.set_default_timeout(120000)
62
+ await page.goto("https://chatgpt.com/", wait_until="domcontentloaded")
63
+
64
+ await page.wait_for_selector('#prompt-textarea', timeout=60000)
65
+ await page.fill('#prompt-textarea', prompt)
66
+ await asyncio.sleep(0.5)
67
+ await page.press('#prompt-textarea', 'Enter')
68
+
69
+ await page.wait_for_selector('[data-message-author-role="assistant"]', timeout=120000)
70
+
71
+ last_text = ""
72
+ unchanged_count = 0
73
+ while unchanged_count < 4:
74
+ messages = await page.query_selector_all('[data-message-author-role="assistant"]')
75
+ if messages:
76
+ current_text = await messages[-1].inner_text()
77
+ if current_text == last_text and current_text.strip() != "":
78
+ unchanged_count += 1
79
+ else:
80
+ last_text = current_text
81
+ unchanged_count = 0
82
+ await asyncio.sleep(0.5)
83
+
84
+ return last_text.strip()
85
+
86
+ except Exception as e:
87
+ print(f"[LITE-SERVER] Error: {e}")
88
+ raise e
89
+ finally:
90
+ await page.close()
91
+ await context.close()
92
+
93
+ def process_request(self, prompt: str):
94
+ if not self.ready_event.wait(timeout=60):
95
+ raise Exception("Error From Browser")
96
+
97
+ future = asyncio.run_coroutine_threadsafe(self._talk_to_chatgpt(prompt), self.loop)
98
+ return future.result(timeout=120)
99
+
100
+ browser_engine = AsyncBrowserThread()
101
+ browser_engine.start()
102
+
103
+ # ====================================================================
104
+ # Smart Prompt Builder
105
+ # ====================================================================
106
+ def format_prompt(messages, tools=None):
107
+ parts = []
108
+ system_parts = []
109
+ has_tool_results = False
110
+ user_question = ""
111
+
112
+ for msg in messages:
113
+ role = msg.get("role", "")
114
+ msg_type = msg.get("type", "")
115
+ content = msg.get("content", "")
116
+
117
+ if isinstance(content, list):
118
+ text_parts = []
119
+ for item in content:
120
+ if isinstance(item, dict):
121
+ text_parts.append(item.get("text", item.get("content", str(item))))
122
+ else:
123
+ text_parts.append(str(item))
124
+ content = "\n".join(text_parts)
125
+
126
+ if role == "system":
127
+ system_parts.append(content)
128
+ elif role == "tool":
129
+ has_tool_results = True
130
+ tool_name = msg.get("name", "tool")
131
+ parts.append(f"[TOOL RESULT from '{tool_name}']:\n{content}")
132
+ elif msg_type == "function_call_output":
133
+ has_tool_results = True
134
+ call_id = msg.get("call_id", "")
135
+ output_content = msg.get("output", content)
136
+ parts.append(f"[TOOL RESULT (call_id: {call_id})]:\n{output_content}")
137
+ elif msg_type == "function_call":
138
+ func_name = msg.get("name", "?")
139
+ func_args = msg.get("arguments", "{}")
140
+ parts.append(f"[PREVIOUS TOOL CALL: Called '{func_name}' with arguments: {func_args}]")
141
+ elif role == "assistant":
142
+ assistant_content = content if content else ""
143
+ tool_calls_in_msg = msg.get("tool_calls", [])
144
+ if tool_calls_in_msg:
145
+ tc_descriptions = []
146
+ for tc in tool_calls_in_msg:
147
+ func = tc.get("function", {})
148
+ tc_descriptions.append(f"Called '{func.get('name', '?')}' with: {func.get('arguments', '{}')}")
149
+ assistant_content += "\n[Previous tool calls: " + "; ".join(tc_descriptions) + "]"
150
+ if assistant_content.strip():
151
+ parts.append(f"[Assistant]: {assistant_content}")
152
+ elif role == "user" or (msg_type == "message" and role != "system"):
153
+ user_question = content
154
+ parts.append(content)
155
+ has_tool_results = False
156
+ elif content:
157
+ parts.append(content)
158
+
159
+ final = ""
160
+
161
+ if system_parts:
162
+ if tools and not has_tool_results:
163
+ final += "=== YOUR ROLE ===\n"
164
+ final += "\n\n".join(system_parts)
165
+ final += "\n=== END OF ROLE ===\n\n"
166
+ else:
167
+ final += "=== SYSTEM INSTRUCTIONS (FOLLOW STRICTLY) ===\n"
168
+ final += "\n\n".join(system_parts)
169
+ final += "\n=== END OF INSTRUCTIONS ===\n\n"
170
+
171
+ if tools and not has_tool_results:
172
+ final += format_tools_instruction(tools, user_question)
173
+
174
+ if has_tool_results:
175
+ final += "=== CONTEXT FROM TOOLS ===\n"
176
+ final += "The following information was retrieved by the tools you requested.\n"
177
+ final += "Use ONLY this information to answer the user's question.\n\n"
178
+
179
+ if parts:
180
+ final += "\n".join(parts)
181
+
182
+ if has_tool_results:
183
+ final += "\n\n=== INSTRUCTION ===\n"
184
+ final += "Now answer the user's question based ONLY on the tool results above.\n"
185
+
186
+ return final
187
+
188
+ def format_tools_instruction(tools, user_question=""):
189
+ instruction = "\n=== MANDATORY TOOL USAGE ===\n"
190
+ instruction += "You MUST use one of the tools below to answer this question.\n"
191
+ instruction += "Do NOT answer directly. Do NOT say you don't have information.\n"
192
+ instruction += "You MUST respond with ONLY a JSON object to call the tool.\n\n"
193
+
194
+ instruction += "RESPONSE FORMAT - respond with ONLY this JSON, nothing else:\n"
195
+ instruction += '{"tool_calls": [{"name": "TOOL_NAME", "arguments": {"param": "value"}}]}\n\n'
196
+
197
+ instruction += "RULES:\n"
198
+ instruction += "- Your ENTIRE response must be valid JSON only\n"
199
+ instruction += "- No markdown, no code blocks, no explanation\n"
200
+ instruction += "- No text before or after the JSON\n\n"
201
+
202
+ instruction += "Available tools:\n\n"
203
+
204
+ for tool in tools:
205
+ func = tool.get("function", tool)
206
+ name = func.get("name", "unknown")
207
+ desc = func.get("description", "No description")
208
+ params = func.get("parameters", {})
209
+
210
+ instruction += f"Tool: {name}\n"
211
+ instruction += f"Description: {desc}\n"
212
+
213
+ if params.get("properties"):
214
+ instruction += "Parameters:\n"
215
+ required_params = params.get("required", [])
216
+ for param_name, param_info in params["properties"].items():
217
+ param_type = param_info.get("type", "string")
218
+ param_desc = param_info.get("description", "")
219
+ is_required = "required" if param_name in required_params else "optional"
220
+ instruction += f" - {param_name} ({param_type}, {is_required}): {param_desc}\n"
221
+ instruction += "\n"
222
+
223
+ instruction += "=== END OF TOOLS ===\n\n"
224
+
225
+ first_tool = tools[0] if tools else {}
226
+ first_func = first_tool.get("function", first_tool)
227
+ first_name = first_func.get("name", "tool")
228
+
229
+ instruction += f'EXAMPLE: If the user asks a question, respond with:\n'
230
+ instruction += '{"tool_calls": [{"name": "' + first_name + '", "arguments": {"input": "the user question here"}}]}\n\n'
231
+
232
+ instruction += "Now respond with the JSON to call the appropriate tool:\n\n"
233
+ return instruction
234
+
235
+ def parse_tool_calls(response_text):
236
+ cleaned = response_text.strip()
237
+ if "```" in cleaned:
238
+ code_block_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?\s*```', cleaned, re.DOTALL)
239
+ if code_block_match:
240
+ cleaned = code_block_match.group(1).strip()
241
+
242
+ json_candidates = [cleaned]
243
+ json_match = re.search(r'\{[\s\S]*"tool_calls"[\s\S]*\}', cleaned)
244
+ if json_match:
245
+ json_candidates.append(json_match.group(0))
246
+
247
+ for candidate in json_candidates:
248
+ try:
249
+ parsed = json.loads(candidate)
250
+ if isinstance(parsed, dict) and "tool_calls" in parsed:
251
+ raw_calls = parsed["tool_calls"]
252
+ if isinstance(raw_calls, list) and len(raw_calls) > 0:
253
+ formatted_calls = []
254
+ for call in raw_calls:
255
+ tool_name = call.get("name", "")
256
+ arguments = call.get("arguments", {})
257
+ if isinstance(arguments, dict):
258
+ arguments_str = json.dumps(arguments, ensure_ascii=False)
259
+ else:
260
+ arguments_str = str(arguments)
261
+
262
+ formatted_calls.append({
263
+ "id": f"call_{uuid.uuid4().hex[:24]}",
264
+ "type": "function",
265
+ "function": {
266
+ "name": tool_name,
267
+ "arguments": arguments_str
268
+ }
269
+ })
270
+ return formatted_calls
271
+ except (json.JSONDecodeError, TypeError, KeyError):
272
+ continue
273
+ return None
274
+
275
+ # ====================================================================
276
+ # FastAPI App
277
+ # ====================================================================
278
+ app = FastAPI(title="mse_ai_api for n8n")
279
+
280
+ @app.post("/v1/chat/completions")
281
+ async def chat_completions(request: Request):
282
+ try:
283
+ data = await request.json()
284
+ except Exception:
285
+ return JSONResponse(status_code=400, content={"error": {"message": "Invalid JSON payload"}})
286
+
287
+ authorization = request.headers.get("authorization", "")
288
+ if not authorization or authorization.replace("Bearer ", "").strip() != API_SECRET_KEY:
289
+ return JSONResponse(status_code=401, content={"error": {"message": "Invalid API Key"}})
290
+
291
+ messages = data.get("messages", [])
292
+ if not messages:
293
+ return JSONResponse(status_code=400, content={"error": {"message": "messages field is required"}})
294
+
295
+ try:
296
+ tools = data.get("tools", None)
297
+ prompt = format_prompt(messages, tools=tools)
298
+ start_time = time.time()
299
+ print(f"[LITE-SERVER] Processing request ({len(prompt)} chars)")
300
+ response_text = browser_engine.process_request(prompt)
301
+ p_tokens = len(prompt.split())
302
+ c_tokens = len(response_text.split())
303
+ tool_calls = None
304
+ if tools:
305
+ tool_calls = parse_tool_calls(response_text)
306
+
307
+ if tool_calls:
308
+ return {
309
+ "id": f"chatcmpl-{uuid.uuid4().hex[:29]}",
310
+ "object": "chat.completion",
311
+ "created": int(start_time),
312
+ "model": data.get("model", "gpt-4o-mini"),
313
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": None,
314
+ "tool_calls": tool_calls}, "finish_reason": "tool_calls"}],
315
+ "usage": {"prompt_tokens": p_tokens, "completion_tokens": c_tokens,
316
+ "total_tokens": p_tokens + c_tokens}
317
+ }
318
+ else:
319
+ return {
320
+ "id": f"chatcmpl-{uuid.uuid4().hex[:29]}",
321
+ "object": "chat.completion",
322
+ "created": int(start_time),
323
+ "model": data.get("model", "gpt-4o-mini"),
324
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": response_text},
325
+ "finish_reason": "stop"}],
326
+ "usage": {"prompt_tokens": p_tokens, "completion_tokens": c_tokens,
327
+ "total_tokens": p_tokens + c_tokens}
328
+ }
329
+ except Exception as e:
330
+ return JSONResponse(status_code=500, content={"error": str(e)})
331
+
332
+
333
+ @app.post("/v1/responses")
334
+ async def responses(request: Request):
335
+ try:
336
+ data = await request.json()
337
+ except Exception:
338
+ return JSONResponse(status_code=400, content={"error": {"message": "Invalid JSON payload"}})
339
+
340
+ authorization = request.headers.get("authorization", "")
341
+ if not authorization or authorization.replace("Bearer ", "").strip() != API_SECRET_KEY:
342
+ return JSONResponse(status_code=401, content={"error": {"message": "Invalid API Key"}})
343
+
344
+ input_data = data.get("input", "")
345
+ if isinstance(input_data, str):
346
+ messages = [{"role": "user", "content": input_data}]
347
+ elif isinstance(input_data, list):
348
+ messages = input_data
349
+ else:
350
+ messages = data.get("messages", [])
351
+
352
+ if not messages:
353
+ return JSONResponse(status_code=400, content={"error": {"message": "input field is required"}})
354
+
355
+ try:
356
+ tools = data.get("tools", None)
357
+ instructions = data.get("instructions", "")
358
+ if instructions:
359
+ messages.insert(0, {"role": "system", "content": instructions})
360
+ prompt = format_prompt(messages, tools=tools)
361
+ start_time = time.time()
362
+ response_text = browser_engine.process_request(prompt)
363
+ p_tokens = len(prompt.split())
364
+ c_tokens = len(response_text.split())
365
+ tool_calls = None
366
+ if tools:
367
+ tool_calls = parse_tool_calls(response_text)
368
+
369
+ if tool_calls:
370
+ output_items = []
371
+ for tc in tool_calls:
372
+ output_items.append({
373
+ "type": "function_call",
374
+ "id": tc["id"],
375
+ "call_id": tc["id"],
376
+ "name": tc["function"]["name"],
377
+ "arguments": tc["function"]["arguments"],
378
+ "status": "completed"
379
+ })
380
+ return {
381
+ "id": f"resp-{uuid.uuid4().hex[:29]}",
382
+ "object": "response",
383
+ "created_at": int(start_time),
384
+ "model": data.get("model", "gpt-4o-mini"),
385
+ "status": "completed",
386
+ "output": output_items,
387
+ "usage": {"input_tokens": p_tokens, "output_tokens": c_tokens,
388
+ "total_tokens": p_tokens + c_tokens}
389
+ }
390
+ else:
391
+ return {
392
+ "id": f"resp-{uuid.uuid4().hex[:29]}",
393
+ "object": "response",
394
+ "created_at": int(start_time),
395
+ "model": data.get("model", "gpt-4o-mini"),
396
+ "status": "completed",
397
+ "output": [{"type": "message", "role": "assistant",
398
+ "content": [{"type": "output_text", "text": response_text}]}],
399
+ "usage": {"input_tokens": p_tokens, "output_tokens": c_tokens,
400
+ "total_tokens": p_tokens + c_tokens}
401
+ }
402
+ except Exception as e:
403
+ return JSONResponse(status_code=500, content={"error": str(e)})
404
+
405
+
406
+ @app.get("/v1/models")
407
+ async def list_models():
408
+ return {"object": "list",
409
+ "data": [{"id": "gpt-4o-mini", "object": "model", "owned_by": "mse_ai_api"}]}
410
+
411
+
412
+ # ── تغيير 1: إضافة /health endpoint (مطلوب لـ HuggingFace healthcheck)
413
+ @app.get("/health")
414
+ @app.get("/")
415
+ async def health_check():
416
+ return {"status": "running", "message": "mse_ai_api Server is active!"}
417
+
418
+
419
+ if __name__ == "__main__":
420
+ import uvicorn
421
+ # ── تغيير 2: port 7777 → 7860 (مطلوب لـ HuggingFace)
422
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.110.0
2
+ uvicorn==0.27.1
3
+ playwright==1.42.0
4
+ pydantic==2.6.3
5
+ python-multipart==0.0.9