import os import json import time import logging import subprocess import re import asyncio from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, Generator, List, Optional import g4f from fastapi import FastAPI, HTTPException, Request from fastapi.responses import StreamingResponse, JSONResponse # ===================================================== # LOGGING # ===================================================== logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" ) logger = logging.getLogger("g4f-agent") # ===================================================== # THREAD POOL # ===================================================== _POOL = ThreadPoolExecutor(max_workers=8) # ===================================================== # COOKIES # ===================================================== def _load_cookies_raw() -> Dict[str, Any]: raw_env = (os.getenv("COOKIES_JSON") or "").strip() if raw_env: try: return json.loads(raw_env) except Exception as e: logger.warning(f"Cookies env error: {e}") try: if os.path.exists("cookies.json"): with open("cookies.json", "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.warning(f"Cookies file error: {e}") return {} def load_cookies() -> str: data = _load_cookies_raw() if not data: return "⚠️ No Cookies" try: from g4f.cookies import set_cookies except Exception: return "⚠️ set_cookies unavailable" for domain, vals in data.items(): try: dom = domain if "." in domain else f".{domain}.com" if isinstance(vals, list): vals = {x["name"]: x["value"] for x in vals if isinstance(x, dict)} if isinstance(vals, dict): set_cookies(dom, vals) except Exception as e: logger.warning(f"Cookie set error [{domain}]: {e}") return "✅ Cookies Loaded" COOKIE_STATUS = load_cookies() logger.info(f"Cookie status: {COOKIE_STATUS}") # ===================================================== # PROVIDERS & MODELS # ===================================================== def get_provider(name: str): try: return getattr(g4f.Provider, name) except Exception: return None QWEN_PROVIDER = get_provider("Qwen") QWEN_MODELS = [ "qwen-max", "qwen-max-latest", "qwen3.7-max", "qwen2.5-max", "qwen2.5-72b-instruct", "qwen-plus", "qwen-turbo", "", ] CLAUDE_MODEL_MAP = { "claude-opus-4-6": "qwen-max", "claude-sonnet-4-6": "qwen-max", "claude-haiku-4-5": "qwen-plus", } # ===================================================== # CACHE (للوكيل فقط) # ===================================================== _CACHE: Dict[str, str] = {} _CACHE_TS: Dict[str, float] = {} def cache_get(key: str) -> Optional[str]: ts = _CACHE_TS.get(key) if ts and (time.time() - ts) < 300: return _CACHE.get(key) return None def cache_set(key: str, val: str): _CACHE[key] = val _CACHE_TS[key] = time.time() # ===================================================== # CHUNK CLEANER # ===================================================== def _clean_chunk(chunk) -> str: try: if isinstance(chunk, dict): if "choices" in chunk and chunk["choices"]: delta = chunk["choices"][0].get("delta", {}) return delta.get("content") or delta.get("text") or "" return chunk.get("content") or chunk.get("text") or "" if isinstance(chunk, str): s = chunk.strip() if s.startswith("{"): try: d = json.loads(s) if d.get("code") == "Not_Found": return "" if "choices" in d and d["choices"]: return d["choices"][0].get("delta", {}).get("content", "") return d.get("content") or d.get("text") or "" except Exception: pass return chunk.replace("\\n", "\n") return str(chunk) except Exception: return "" # ===================================================== # ✅ إصلاح الذاكرة — تنظيف الرسائل بدون قطعها # ===================================================== def build_messages(raw_messages: List[Dict]) -> List[Dict]: """ يُنظّف قائمة الرسائل القادمة من OpenAI ويُعيدها كاملة. - يدعم content كـ string أو list (multi-part) - يحتفظ بكل الرسائل (user + assistant + system) - لا يقطع السياق نهائياً """ result = [] for m in raw_messages: role = m.get("role", "user") content = m.get("content", "") # دعم content كـ list من blocks if isinstance(content, list): parts = [] for block in content: if not isinstance(block, dict): continue btype = block.get("type", "") if btype in ("text", "input_text"): text = block.get("text", "") if text.strip(): parts.append(text) content = "\n".join(parts) else: content = str(content) if content else "" # لا نُضيف رسائل فارغة if content.strip() or role == "system": result.append({"role": role, "content": content}) logger.debug(f"build_messages: {len(raw_messages)} → {len(result)} messages") return result # ===================================================== # ✅ وضع النموذج: تدفق SSE حقيقي token بـ token # ===================================================== def _stream_generator( messages: List[Dict], model_name: str ) -> Generator[str, None, None]: """ Generator متزامن — يُنتج chunks فور وصولها من g4f. يُستخدم حصرياً في وضع النموذج العادي. """ target = CLAUDE_MODEL_MAP.get(model_name, model_name) pobj = QWEN_PROVIDER if not pobj: yield "❌ مزود Qwen غير متاح." return candidates = [target] + [m for m in QWEN_MODELS if m != target] for model in candidates: try: stream = g4f.ChatCompletion.create( model=model, provider=pobj, messages=messages, stream=True, ) got = False for chunk in stream: text = _clean_chunk(chunk) if text: got = True yield text # ← تدفق فوري بدون تجميع if got: logger.info(f"[Stream] ✅ model={model}") return except Exception as e: err = str(e) if "Not_Found" in err or "Model not found" in err: continue logger.warning(f"[Stream] model={model} error: {e}") continue yield "\n❌ فشل الاتصال بـ Qwen نهائياً." async def stream_to_queue( messages: List[Dict], model_name: str, queue: asyncio.Queue ): """يُشغّل _stream_generator في thread ويضع النتائج في asyncio.Queue""" loop = asyncio.get_running_loop() def _run(): try: for chunk in _stream_generator(messages, model_name): asyncio.run_coroutine_threadsafe(queue.put(("chunk", chunk)), loop) except Exception as e: asyncio.run_coroutine_threadsafe(queue.put(("error", str(e))), loop) finally: asyncio.run_coroutine_threadsafe(queue.put(("done", None)), loop) _POOL.submit(_run) # ===================================================== # ✅ وضع الوكيل: ask_sync بدون تدفق (للحلقة الداخلية) # ===================================================== def ask_sync(messages: List[Dict], model_name: str) -> str: """ يُرجع الرد كاملاً دفعة واحدة. يُستخدم حصرياً داخل حلقة الوكيل. """ target = CLAUDE_MODEL_MAP.get(model_name, model_name) pobj = QWEN_PROVIDER if not pobj: return "❌ مزود Qwen غير متاح." cache_key = f"sync|{target}|{hash(json.dumps(messages, ensure_ascii=False, sort_keys=True))}" cached = cache_get(cache_key) if cached: logger.debug("[Sync] cache hit") return cached candidates = [target] + [m for m in QWEN_MODELS if m != target] for model in candidates: # جرب بدون stream أولاً ثم مع stream for use_stream in (False, True): try: result = g4f.ChatCompletion.create( model=model, provider=pobj, messages=messages, stream=use_stream, ) if use_stream: text = "".join( _clean_chunk(c) for c in result ) else: text = _clean_chunk(result) if not isinstance(result, str) else result if text.strip(): cache_set(cache_key, text.strip()) logger.info(f"[Sync] ✅ model={model} stream={use_stream}") return text.strip() except Exception as e: err = str(e) if "Not_Found" in err or "Model not found" in err: break # نموذج غير موجود → النموذج التالي logger.warning(f"[Sync] model={model} stream={use_stream} error: {e}") if not use_stream: continue # جرب stream=True لنفس النموذج break return "❌ فشل الاتصال بـ Qwen نهائياً." # ===================================================== # TOOLS # ===================================================== TOOLS_NAMES = {"run_python", "run_shell", "create_file", "read_file", "list_files"} def execute_tool(name: str, args: Dict) -> str: """تنفيذ الأداة وإرجاع النتيجة كنص""" try: if name == "run_python": proc = subprocess.run( ["python3", "-c", args.get("code", "")], capture_output=True, text=True, timeout=30 ) return (proc.stdout or proc.stderr or "✅ لا مخرجات")[:3000] elif name == "run_shell": proc = subprocess.run( args.get("command", ""), shell=True, capture_output=True, text=True, timeout=30 ) return (proc.stdout or proc.stderr or "✅ تم التنفيذ")[:3000] elif name == "create_file": path = args.get("path", "") if os.path.dirname(path): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(args.get("content", "")) return f"✅ تم إنشاء: {path}" elif name == "read_file": with open(args.get("path", ""), "r", encoding="utf-8") as f: return f.read()[:4000] elif name == "list_files": entries = os.listdir(args.get("directory", ".")) return "\n".join(entries) or "(فارغ)" return f"❌ أداة غير معروفة: {name}" except subprocess.TimeoutExpired: return f"❌ انتهت المهلة: {name}" except Exception as e: return f"❌ خطأ في {name}: {e}" # ===================================================== # AGENT SYSTEM PROMPT # ===================================================== AGENT_SYSTEM = """أنت مساعد ذكي يمتلك أدوات. عند الحاجة لأداة استخدم هذا التنسيق حصراً: {"name": "اسم_الأداة", "arguments": {"مفتاح": "قيمة"}} الأدوات المتاحة: • run_python(code) — تنفيذ Python • run_shell(command) — تنفيذ shell • create_file(path,content)— إنشاء ملف • read_file(path) — قراءة ملف • list_files(directory) — عرض مجلد قواعد: 1. لا تضع نصاً قبل/بعد . 2. بعد استلام النتيجة واصل التفكير أو استخدم أداة أخرى. 3. أنهِ بإجابة نهائية بدون .""" def _parse_tool_call(text: str) -> Optional[Dict]: m = re.search(r"\s*(.*?)\s*", text, re.DOTALL) if not m: return None try: d = json.loads(m.group(1)) if isinstance(d, dict) and "name" in d: return d except Exception: pass return None # ===================================================== # ✅ وضع الوكيل: SSE حقيقي مع أحداث مفصّلة # أحداث: thinking | tool_call | tool_result | token | done | error # ===================================================== def _sse_event(event_type: str, data: Dict) -> str: """يُنشئ سطر SSE مُنسَّق""" return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" async def agent_sse_generator( messages: List[Dict], model_name: str, completion_id: str, created: int ): """ ✅ الوكيل مع SSE حقيقي: - يُرسل حدث tool_call عند طلب أداة - يُرسل حدث tool_result بعد تنفيذها - يُرسل الإجابة النهائية كـ tokens عبر OpenAI chunk format - لا يستخدم تدفق النموذج داخلياً (ask_sync فقط) """ loop = asyncio.get_running_loop() # بناء رسائل الوكيل مع system prompt agent_msgs: List[Dict] = [] existing_system = next( (m["content"] for m in messages if m.get("role") == "system"), None ) system_content = AGENT_SYSTEM if existing_system: system_content += f"\n\n---\n{existing_system}" agent_msgs.append({"role": "system", "content": system_content}) for m in messages: if m.get("role") != "system": agent_msgs.append(m) # ── Chunk أول: تعريف الدور ── yield f"data: {json.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created, 'model': model_name, 'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': ''}, 'finish_reason': None}]}, ensure_ascii=False)}\n\n" max_iter = 10 for iteration in range(1, max_iter + 1): logger.info(f"[Agent] Iter {iteration}/{max_iter}") # استدعاء النموذج بدون تدفق response = await loop.run_in_executor( _POOL, ask_sync, agent_msgs, model_name ) tool_call = _parse_tool_call(response) # ── لا أداة → إجابة نهائية → أرسلها كـ tokens ── if tool_call is None: logger.info(f"[Agent] Final answer at iter={iteration}") chunk_size = 30 for i in range(0, len(response), chunk_size): piece = response[i:i + chunk_size] yield f"data: {json.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': piece}, 'finish_reason': None}]}, ensure_ascii=False)}\n\n" await asyncio.sleep(0.003) # chunk أخير yield f"data: {json.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created, 'model': model_name, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]}, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" return # ── يوجد طلب أداة ── tool_name = tool_call.get("name", "") tool_args = tool_call.get("arguments", {}) # حدث: tool_call (إخبار العميل أن الوكيل يُنفّذ أداة) yield _sse_event("tool_call", { "id": completion_id, "iteration": iteration, "tool": tool_name, "args": tool_args, }) if tool_name not in TOOLS_NAMES: err_result = f"❌ أداة غير موجودة: {tool_name}. المتاحة: {', '.join(TOOLS_NAMES)}" yield _sse_event("tool_result", { "id": completion_id, "tool": tool_name, "result": err_result, "ok": False, }) agent_msgs.append({"role": "assistant", "content": response}) agent_msgs.append({"role": "user", "content": f"\n{err_result}\n"}) continue # تنفيذ الأداة في thread منفصل logger.info(f"[Agent] Executing tool: {tool_name}({tool_args})") tool_result = await loop.run_in_executor( _POOL, execute_tool, tool_name, tool_args ) logger.info(f"[Agent] Tool result: {tool_result[:80]}…") # حدث: tool_result yield _sse_event("tool_result", { "id": completion_id, "tool": tool_name, "result": tool_result, "ok": True, }) # إضافة رد النموذج ونتيجة الأداة للسياق (الذاكرة) agent_msgs.append({"role": "assistant", "content": response}) agent_msgs.append({ "role": "user", "content": ( f"\n" f"الأداة: {tool_name}\n" f"النتيجة:\n{tool_result}\n" f"\n\n" f"واصل." ) }) # تجاوز الحد الأقصى timeout_msg = "⚠️ تجاوز الوكيل الحد الأقصى من التكرارات." yield _sse_event("error", {"id": completion_id, "message": timeout_msg}) yield f"data: {json.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': timeout_msg}, 'finish_reason': 'stop'}]}, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" # ===================================================== # FASTAPI # ===================================================== app = FastAPI(title="G4F Agent API v2") API_KEY = os.getenv("API_KEY", "mysecretkey123") def verify(request: Request): auth = request.headers.get("Authorization", "") if auth.startswith("Bearer ") and auth[7:] == API_KEY: return True if request.headers.get("X-API-Key", "") == API_KEY: return True raise HTTPException(status_code=401, detail="Invalid or missing API key") @app.api_route("/", methods=["GET", "HEAD"]) async def root(): return JSONResponse({ "status": "✅ running", "version": "2.0", "modes": "stream=true → SSE tokens | use_agent=true → SSE agent events", "tools": list(TOOLS_NAMES), "endpoint": "/v1/chat/completions", }) @app.get("/v1/models") async def list_models(request: Request): verify(request) return JSONResponse({ "object": "list", "data": [ {"id": "claude-opus-4-6", "object": "model", "owned_by": "anthropic"}, {"id": "claude-sonnet-4-6", "object": "model", "owned_by": "anthropic"}, {"id": "claude-haiku-4-5", "object": "model", "owned_by": "anthropic"}, ] }) # ===================================================== # ✅ MAIN ENDPOINT — المنطق الكامل # ===================================================== @app.api_route("/v1/chat/completions", methods=["POST"]) async def chat_completions(request: Request): verify(request) body = await request.json() model = body.get("model", "claude-sonnet-4-6") raw_messages = body.get("messages", []) do_stream = body.get("stream", False) tools = body.get("tools", []) use_agent = body.get("use_agent", False) if not raw_messages: raise HTTPException(status_code=400, detail="messages required") # ✅ بناء الرسائل كاملة مع الذاكرة clean_msgs = build_messages(raw_messages) completion_id = f"chatcmpl-{int(time.time())}" created = int(time.time()) is_agent = use_agent or bool(tools) # ───────────────────────────────────────────── # ✅ وضع الوكيل → SSE مع أحداث tool_call/tool_result # يعمل دائماً كـ SSE بغض النظر عن stream في الطلب # ───────────────────────────────────────────── if is_agent: logger.info(f"[API] Agent SSE mode — model={model}, msgs={len(clean_msgs)}") return StreamingResponse( agent_sse_generator(clean_msgs, model, completion_id, created), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, ) # ───────────────────────────────────────────── # ✅ وضع النموذج العادي مع تدفق → SSE حقيقي token بـ token # ───────────────────────────────────────────── if do_stream: logger.info(f"[API] Stream mode — model={model}, msgs={len(clean_msgs)}") async def normal_stream(): # chunk أول: تعريف الدور first = { "id": completion_id, "object": "chat.completion.chunk", "created": created, "model": model, "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}] } yield f"data: {json.dumps(first, ensure_ascii=False)}\n\n" # ضع stream_generator في queue queue: asyncio.Queue = asyncio.Queue() await stream_to_queue(clean_msgs, model, queue) while True: event_type, data = await queue.get() if event_type == "done": break if event_type == "error": err_chunk = { "id": completion_id, "object": "chat.completion.chunk", "created": created, "model": model, "choices": [{"index": 0, "delta": {"content": f"\n❌ {data}"}, "finish_reason": "stop"}] } yield f"data: {json.dumps(err_chunk, ensure_ascii=False)}\n\n" break # chunk عادي بالـ token الفعلي chunk = { "id": completion_id, "object": "chat.completion.chunk", "created": created, "model": model, "choices": [{"index": 0, "delta": {"content": data}, "finish_reason": None}] } yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" # chunk أخير final = { "id": completion_id, "object": "chat.completion.chunk", "created": created, "model": model, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}] } yield f"data: {json.dumps(final, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( normal_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, ) # ───────────────────────────────────────────── # ✅ وضع النموذج العادي بدون تدفق # ───────────────────────────────────────────── logger.info(f"[API] Sync mode — model={model}, msgs={len(clean_msgs)}") loop = asyncio.get_running_loop() response_text = await loop.run_in_executor(_POOL, ask_sync, clean_msgs, model) prompt_tokens = sum(len(m.get("content", "")) for m in clean_msgs) // 4 completion_tokens = len(response_text) // 4 return JSONResponse({ "id": completion_id, "object": "chat.completion", "created": created, "model": model, "choices": [{ "index": 0, "message": {"role": "assistant", "content": response_text}, "logprobs": None, "finish_reason": "stop" }], "usage": { "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, } }) # ===================================================== # ENTRY POINT # ===================================================== if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", "7860")) logger.info(f"🚀 Starting on port {port}") uvicorn.run(app, host="0.0.0.0", port=port)