Spaces:
Paused
Paused
| import json | |
| import os | |
| import time | |
| import uuid | |
| import logging | |
| import asyncio | |
| from typing import Optional | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, Request, HTTPException | |
| from fastapi.responses import StreamingResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from duck_client import DuckAIClient | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| logger = logging.getLogger(__name__) | |
| CONFIG_FILE = os.environ.get("CONFIG_FILE", "config.json") | |
| def load_config() -> dict: | |
| try: | |
| with open(CONFIG_FILE, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| except FileNotFoundError: | |
| return {} | |
| config = load_config() | |
| def env_or_config(env_name: str, config_key: str, default=None, cast=None): | |
| value = os.environ.get(env_name) | |
| if value is None: | |
| value = config.get(config_key, default) | |
| if value is None: | |
| return None | |
| if cast is bool: | |
| if isinstance(value, bool): | |
| return value | |
| return str(value).strip().lower() in {"1", "true", "yes", "on"} | |
| if cast is int: | |
| return int(value) | |
| if cast: | |
| return cast(value) | |
| return value | |
| API_KEY = env_or_config("API_KEY", "api_key", "sk-duck-ai") | |
| PROXY = env_or_config("PROXY", "proxy", None) | |
| HOST = env_or_config("HOST", "host", "0.0.0.0") | |
| PORT = env_or_config("PORT", "port", 7860, cast=int) | |
| DEFAULT_MODEL = env_or_config("DEFAULT_MODEL", "default_model", "claude-haiku-4-5") | |
| ASSISTANT_NAME = env_or_config("ASSISTANT_NAME", "assistant_name", None) | |
| SYSTEM_PROMPT = env_or_config("SYSTEM_PROMPT", "system_prompt", None) | |
| WEB_SEARCH = env_or_config("WEB_SEARCH", "web_search", True, cast=bool) | |
| POOL_SIZE = env_or_config("POOL_SIZE", "pool_size", 2, cast=int) | |
| # Single shared client with page pool | |
| _shared_client: Optional[DuckAIClient] = None | |
| _client_lock = asyncio.Lock() | |
| async def get_client() -> DuckAIClient: | |
| global _shared_client | |
| async with _client_lock: | |
| if _shared_client is None: | |
| _shared_client = DuckAIClient( | |
| proxy=PROXY, | |
| model=DEFAULT_MODEL, | |
| assistant_name=ASSISTANT_NAME, | |
| system_prompt=SYSTEM_PROMPT, | |
| pool_size=POOL_SIZE, | |
| ) | |
| return _shared_client | |
| async def return_client(c: DuckAIClient): | |
| return None | |
| async def lifespan(app: FastAPI): | |
| logger.info("Duck.ai 2API server starting on %s:%s", HOST, PORT) | |
| logger.info("Default model: %s", DEFAULT_MODEL) | |
| logger.info("Web search: %s", WEB_SEARCH) | |
| logger.info("Pool size: %s", POOL_SIZE) | |
| logger.info("Proxy: %s", PROXY or "None") | |
| yield | |
| global _shared_client | |
| if _shared_client: | |
| await _shared_client.close() | |
| _shared_client = None | |
| logger.info("Server shutdown complete") | |
| app = FastAPI(title="Duck.ai 2API", lifespan=lifespan) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| def verify_auth(request: Request): | |
| if not API_KEY: | |
| return | |
| auth = request.headers.get("Authorization", "") | |
| if auth.startswith("Bearer "): | |
| token = auth[7:] | |
| else: | |
| token = auth | |
| if token != API_KEY: | |
| raise HTTPException(status_code=401, detail="Invalid API key") | |
| MODEL_MAP = { | |
| "claude-haiku-4-5": "claude-haiku-4-5", | |
| "claude-3-haiku": "claude-haiku-4-5", | |
| "claude-3-5-haiku": "claude-haiku-4-5", | |
| "claude-3-haiku-20240307": "claude-haiku-4-5", | |
| "gpt-4o-mini": "gpt-4o-mini", | |
| "gpt-4o": "gpt-4o-mini", | |
| "gpt-3.5-turbo": "gpt-4o-mini", | |
| "llama-3.3-70b": "meta-llama/Llama-3.3-70B-Instruct-Turbo", | |
| "mixtral-8x7b": "mistralai/Mixtral-8x7B-Instruct-v0.1", | |
| "o3-mini": "o3-mini", | |
| } | |
| def map_model(model: str) -> str: | |
| return MODEL_MAP.get(model, DEFAULT_MODEL) | |
| async def list_models(request: Request): | |
| verify_auth(request) | |
| models = [ | |
| {"id": "claude-haiku-4-5", "object": "model", "owned_by": "anthropic"}, | |
| {"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"}, | |
| {"id": "o3-mini", "object": "model", "owned_by": "openai"}, | |
| {"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "object": "model", "owned_by": "meta"}, | |
| {"id": "mistralai/Mixtral-8x7B-Instruct-v0.1", "object": "model", "owned_by": "mistral"}, | |
| ] | |
| return {"object": "list", "data": models} | |
| async def chat_completions(request: Request): | |
| verify_auth(request) | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| raise HTTPException(status_code=400, detail="Invalid JSON body") | |
| messages = body.get("messages", []) | |
| if not messages: | |
| raise HTTPException(status_code=400, detail="messages is required") | |
| req_model = body.get("model", DEFAULT_MODEL) | |
| duck_model = map_model(req_model) | |
| stream = body.get("stream", False) | |
| web_search = body.get("web_search", WEB_SEARCH) | |
| custom_instructions = None | |
| assistant_name = ASSISTANT_NAME | |
| for msg in messages: | |
| if msg.get("role") == "system": | |
| custom_instructions = msg.get("content", "") | |
| break | |
| completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}" | |
| created = int(time.time()) | |
| client = await get_client() | |
| client.model = duck_model | |
| if stream: | |
| return StreamingResponse( | |
| _stream_response( | |
| client, | |
| messages, | |
| web_search, | |
| custom_instructions, | |
| assistant_name, | |
| completion_id, | |
| created, | |
| req_model, | |
| ), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "Connection": "keep-alive", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| return await _non_stream_response( | |
| client, | |
| messages, | |
| web_search, | |
| custom_instructions, | |
| assistant_name, | |
| completion_id, | |
| created, | |
| req_model, | |
| ) | |
| async def _stream_response(client: DuckAIClient, messages: list, | |
| web_search: bool, custom_instructions: str, | |
| assistant_name: str, completion_id: str, | |
| created: int, model: str): | |
| try: | |
| first_chunk = { | |
| "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_chunk)}\n\n" | |
| search_sources = [] | |
| async for event in client.chat_stream( | |
| messages=messages, | |
| web_search=web_search, | |
| custom_instructions=custom_instructions, | |
| assistant_name=assistant_name, | |
| ): | |
| etype = event.get("type") | |
| if etype == "text": | |
| text = event.get("data", "") | |
| if text and isinstance(text, str): | |
| chunk = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": model, | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {"content": text}, | |
| "finish_reason": None, | |
| }], | |
| } | |
| yield f"data: {json.dumps(chunk)}\n\n" | |
| elif etype == "message": | |
| data = event.get("data", {}) | |
| text = "" | |
| if isinstance(data, dict): | |
| text = data.get("message", data.get("content", "")) | |
| elif isinstance(data, str): | |
| text = data | |
| if text and isinstance(text, str): | |
| chunk = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": model, | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {"content": text}, | |
| "finish_reason": None, | |
| }], | |
| } | |
| yield f"data: {json.dumps(chunk)}\n\n" | |
| elif etype == "search_source": | |
| src = event.get("data", {}) | |
| if isinstance(src, dict) and src.get("url"): | |
| search_sources.append(src) | |
| elif etype in ("search_begin", "search_results", "search_end"): | |
| pass | |
| elif etype == "done": | |
| break | |
| elif etype == "event": | |
| data = event.get("data", {}) | |
| if isinstance(data, dict): | |
| msg = data.get("message", data.get("content", "")) | |
| if msg and isinstance(msg, str): | |
| chunk = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": model, | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {"content": msg}, | |
| "finish_reason": None, | |
| }], | |
| } | |
| yield f"data: {json.dumps(chunk)}\n\n" | |
| if search_sources: | |
| refs = "\n\n---\n**搜索结果:**\n" | |
| for i, src in enumerate(search_sources[:8], 1): | |
| title = src.get("title", "") | |
| url = src.get("url", "") | |
| site = src.get("site", "") | |
| favicon = f"https://www.google.com/s2/favicons?domain={site}&sz=32" if site else "" | |
| if title and url: | |
| icon = f" " if favicon else "" | |
| refs += f"{i}. {icon}[{title}]({url}) - {site}\n" | |
| chunk = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": model, | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {"content": refs}, | |
| "finish_reason": None, | |
| }], | |
| } | |
| yield f"data: {json.dumps(chunk)}\n\n" | |
| final_chunk = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": model, | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {}, | |
| "finish_reason": "stop", | |
| }], | |
| } | |
| yield f"data: {json.dumps(final_chunk)}\n\n" | |
| yield "data: [DONE]\n\n" | |
| except Exception as e: | |
| logger.error("Stream error: %s", e, exc_info=True) | |
| error_chunk = { | |
| "id": completion_id, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": model, | |
| "choices": [{ | |
| "index": 0, | |
| "delta": {"content": f"\n\n[Error: {str(e)}]"}, | |
| "finish_reason": "stop", | |
| }], | |
| } | |
| yield f"data: {json.dumps(error_chunk)}\n\n" | |
| yield "data: [DONE]\n\n" | |
| finally: | |
| await return_client(client) | |
| async def _non_stream_response(client: DuckAIClient, messages: list, | |
| web_search: bool, custom_instructions: str, | |
| assistant_name: str, completion_id: str, | |
| created: int, model: str): | |
| full_content = "" | |
| search_sources = [] | |
| try: | |
| async for event in client.chat_stream( | |
| messages=messages, | |
| web_search=web_search, | |
| custom_instructions=custom_instructions, | |
| assistant_name=assistant_name, | |
| ): | |
| etype = event.get("type") | |
| if etype == "text": | |
| val = event.get("data", "") | |
| if isinstance(val, str): | |
| full_content += val | |
| elif etype == "message": | |
| data = event.get("data", {}) | |
| if isinstance(data, dict): | |
| msg = data.get("message", data.get("content", "")) | |
| if isinstance(msg, str): | |
| full_content += msg | |
| elif isinstance(data, str): | |
| full_content += data | |
| elif etype == "search_source": | |
| src = event.get("data", {}) | |
| if isinstance(src, dict) and src.get("url"): | |
| search_sources.append(src) | |
| elif etype == "event": | |
| data = event.get("data", {}) | |
| if isinstance(data, dict): | |
| msg = data.get("message", data.get("content", "")) | |
| if isinstance(msg, str): | |
| full_content += msg | |
| elif etype == "done": | |
| break | |
| except Exception as e: | |
| logger.error("Chat error: %s", e, exc_info=True) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| finally: | |
| await return_client(client) | |
| if search_sources: | |
| refs = "\n\n---\n**搜索结果:**\n" | |
| for i, src in enumerate(search_sources[:8], 1): | |
| title = src.get("title", "") | |
| url = src.get("url", "") | |
| site = src.get("site", "") | |
| favicon = f"https://www.google.com/s2/favicons?domain={site}&sz=32" if site else "" | |
| if title and url: | |
| icon = f" " if favicon else "" | |
| refs += f"{i}. {icon}[{title}]({url}) - {site}\n" | |
| full_content += refs | |
| prompt_tokens = sum(len(m.get("content", "")) for m in messages) // 4 | |
| completion_tokens = len(full_content) // 4 | |
| return JSONResponse({ | |
| "id": completion_id, | |
| "object": "chat.completion", | |
| "created": created, | |
| "model": model, | |
| "choices": [{ | |
| "index": 0, | |
| "message": {"role": "assistant", "content": full_content}, | |
| "finish_reason": "stop", | |
| }], | |
| "usage": { | |
| "prompt_tokens": prompt_tokens, | |
| "completion_tokens": completion_tokens, | |
| "total_tokens": prompt_tokens + completion_tokens, | |
| }, | |
| }) | |
| async def root(): | |
| return { | |
| "service": "Duck.ai 2API", | |
| "status": "running", | |
| "model": DEFAULT_MODEL, | |
| "docs": "/docs", | |
| "health": "/health", | |
| } | |
| async def health(): | |
| client = None | |
| try: | |
| client = await get_client() | |
| return { | |
| "status": "ok", | |
| "model": DEFAULT_MODEL, | |
| "pool": client.pool_status(), | |
| } | |
| except Exception as e: | |
| return JSONResponse( | |
| status_code=503, | |
| content={"status": "error", "detail": str(e)}, | |
| ) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("server:app", host=HOST, port=PORT, reload=False) | |