Sinketji commited on
Commit
20ab4d8
·
verified ·
1 Parent(s): 9e8f455

Create app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +212 -0
app/main.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import httpx
8
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel, Field
13
+ from pypdf import PdfReader
14
+
15
+ PERPLEXITY_URL = "https://api.perplexity.ai/chat/completions"
16
+
17
+ # Models listed in Perplexity docs for Sonar family.
18
+ SONAR_MODELS = [
19
+ "sonar",
20
+ "sonar-pro",
21
+ "sonar-deep-research",
22
+ "sonar-reasoning",
23
+ "sonar-reasoning-pro",
24
+ ]
25
+
26
+ app = FastAPI(title="Perplexity HF Playground", version="1.0.0")
27
+
28
+ # Allow any origin because UI might be opened from file:// or different domain
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=["*"],
32
+ allow_headers=["*"],
33
+ allow_methods=["*"],
34
+ )
35
+
36
+ static_dir = os.path.join(os.path.dirname(__file__), "static")
37
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
38
+
39
+
40
+ class ChatRequest(BaseModel):
41
+ api_key: str = Field(..., description="Perplexity API key (Bearer token), provided by user in UI.")
42
+ model: str = Field(default="sonar")
43
+ messages: List[Dict[str, Any]] = Field(default_factory=list)
44
+
45
+ # Common knobs (all optional; forwarded as-is)
46
+ temperature: Optional[float] = None
47
+ top_p: Optional[float] = None
48
+ max_tokens: Optional[int] = None
49
+ stream: bool = False
50
+
51
+ # Perplexity-specific (optional)
52
+ search_mode: Optional[str] = None # "web" | "academic" | "sec"
53
+ reasoning_effort: Optional[str] = None # ONLY for sonar-deep-research: "low" | "medium" | "high"
54
+ search_domain_filter: Optional[List[str]] = None
55
+ return_images: Optional[bool] = None
56
+ return_related_questions: Optional[bool] = None
57
+ search_recency_filter: Optional[str] = None
58
+ disable_search: Optional[bool] = None
59
+ web_search_options: Optional[Dict[str, Any]] = None
60
+
61
+
62
+ def _auth_headers(api_key: str) -> Dict[str, str]:
63
+ api_key = (api_key or "").strip()
64
+ if not api_key:
65
+ raise HTTPException(status_code=400, detail="Missing api_key")
66
+ return {
67
+ "Authorization": f"Bearer {api_key}",
68
+ "Content-Type": "application/json",
69
+ "Accept": "application/json",
70
+ }
71
+
72
+
73
+ @app.get("/")
74
+ def root():
75
+ return FileResponse(os.path.join(static_dir, "index.html"))
76
+
77
+
78
+ @app.get("/api/health")
79
+ def health():
80
+ return {"ok": True}
81
+
82
+
83
+ @app.get("/api/models")
84
+ def models():
85
+ return {"ok": True, "models": SONAR_MODELS}
86
+
87
+
88
+ async def _perplexity_post_json(payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
89
+ async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
90
+ resp = await client.post(PERPLEXITY_URL, headers=_auth_headers(api_key), json=payload)
91
+ if resp.status_code >= 400:
92
+ # Avoid leaking key; return error details safely
93
+ raise HTTPException(status_code=resp.status_code, detail=resp.text)
94
+ return resp.json()
95
+
96
+
97
+ @app.post("/api/chat")
98
+ async def chat(req: ChatRequest):
99
+ if req.model not in SONAR_MODELS:
100
+ # Keep strict to prevent surprising failures. You can relax later.
101
+ raise HTTPException(status_code=400, detail=f"Unsupported model: {req.model}")
102
+
103
+ if not req.messages:
104
+ raise HTTPException(status_code=400, detail="messages[] is required")
105
+
106
+ # Build payload by forwarding only non-null fields
107
+ payload: Dict[str, Any] = {
108
+ "model": req.model,
109
+ "messages": req.messages,
110
+ "stream": req.stream,
111
+ }
112
+
113
+ optional_fields = [
114
+ "temperature",
115
+ "top_p",
116
+ "max_tokens",
117
+ "search_mode",
118
+ "reasoning_effort",
119
+ "search_domain_filter",
120
+ "return_images",
121
+ "return_related_questions",
122
+ "search_recency_filter",
123
+ "disable_search",
124
+ "web_search_options",
125
+ ]
126
+ for f in optional_fields:
127
+ v = getattr(req, f)
128
+ if v is not None:
129
+ payload[f] = v
130
+
131
+ if not req.stream:
132
+ data = await _perplexity_post_json(payload, req.api_key)
133
+ # Normalized output for frontend convenience
134
+ content = (
135
+ (data.get("choices") or [{}])[0].get("message", {}).get("content")
136
+ if isinstance(data, dict)
137
+ else None
138
+ )
139
+ citations = data.get("citations") if isinstance(data, dict) else None
140
+ return JSONResponse({"ok": True, "content": content, "citations": citations, "raw": data})
141
+
142
+ # STREAMING (SSE passthrough)
143
+ async def event_generator():
144
+ headers = _auth_headers(req.api_key)
145
+ async with httpx.AsyncClient(timeout=None) as client:
146
+ async with client.stream("POST", PERPLEXITY_URL, headers=headers, json=payload) as r:
147
+ if r.status_code >= 400:
148
+ body = await r.aread()
149
+ # SSE error frame
150
+ yield f"event: error\ndata: {body.decode('utf-8', errors='ignore')}\n\n"
151
+ return
152
+
153
+ async for line in r.aiter_lines():
154
+ if not line:
155
+ continue
156
+ # Pass through raw SSE-ish lines. Frontend will parse best-effort.
157
+ yield line + "\n"
158
+
159
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
160
+
161
+
162
+ def _extract_text_from_upload(file: UploadFile, max_chars: int = 120_000) -> str:
163
+ filename = (file.filename or "").lower()
164
+ if filename.endswith(".txt") or (file.content_type or "").startswith("text/"):
165
+ raw = file.file.read()
166
+ text = raw.decode("utf-8", errors="ignore")
167
+ return text[:max_chars]
168
+
169
+ if filename.endswith(".pdf") or file.content_type == "application/pdf":
170
+ reader = PdfReader(file.file)
171
+ chunks: List[str] = []
172
+ for page in reader.pages:
173
+ t = page.extract_text() or ""
174
+ if t.strip():
175
+ chunks.append(t)
176
+ if sum(len(c) for c in chunks) > max_chars:
177
+ break
178
+ return ("\n\n".join(chunks))[:max_chars]
179
+
180
+ raise HTTPException(status_code=400, detail="Only .txt and .pdf supported for upload in this build.")
181
+
182
+
183
+ @app.post("/api/chat-upload")
184
+ async def chat_upload(
185
+ api_key: str = Form(...),
186
+ model: str = Form("sonar"),
187
+ prompt: str = Form(...),
188
+ system_prompt: str = Form("You are a helpful assistant."),
189
+ stream: bool = Form(False),
190
+ file: Optional[UploadFile] = File(None),
191
+ ):
192
+ if model not in SONAR_MODELS:
193
+ raise HTTPException(status_code=400, detail=f"Unsupported model: {model}")
194
+
195
+ file_text = ""
196
+ if file is not None:
197
+ file_text = _extract_text_from_upload(file)
198
+
199
+ user_content = prompt
200
+ if file_text.strip():
201
+ user_content += "\n\n---\nAttached file text (extracted):\n" + file_text
202
+
203
+ req = ChatRequest(
204
+ api_key=api_key,
205
+ model=model,
206
+ messages=[
207
+ {"role": "system", "content": system_prompt},
208
+ {"role": "user", "content": user_content},
209
+ ],
210
+ stream=stream,
211
+ )
212
+ return await chat(req)