Create main.py
Browse files
main.py
ADDED
@@ -0,0 +1,623 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
FastAPI application providing OpenAI-compatible API endpoints using QodoAI.
|
3 |
+
"""
|
4 |
+
|
5 |
+
import json
|
6 |
+
import time
|
7 |
+
import uuid
|
8 |
+
import logging
|
9 |
+
import asyncio
|
10 |
+
from typing import List, Dict, Optional, Union, Generator, Any, AsyncGenerator
|
11 |
+
|
12 |
+
from fastapi import FastAPI, HTTPException, Depends, Request, status
|
13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
14 |
+
from fastapi.responses import StreamingResponse, JSONResponse
|
15 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
16 |
+
from pydantic import BaseModel, Field, validator
|
17 |
+
import uvicorn
|
18 |
+
|
19 |
+
from curl_cffi.requests import Session
|
20 |
+
from curl_cffi import CurlError
|
21 |
+
|
22 |
+
# Configure logging
|
23 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
24 |
+
logger = logging.getLogger(__name__)
|
25 |
+
|
26 |
+
security = HTTPBearer(auto_error=False)
|
27 |
+
|
28 |
+
# ============================================================================
|
29 |
+
# Exception Classes
|
30 |
+
# ============================================================================
|
31 |
+
|
32 |
+
class FailedToGenerateResponseError(Exception):
|
33 |
+
"""Exception raised when response generation fails."""
|
34 |
+
pass
|
35 |
+
|
36 |
+
# ============================================================================
|
37 |
+
# Utility Functions
|
38 |
+
# ============================================================================
|
39 |
+
|
40 |
+
def sanitize_stream(data, intro_value="", to_json=True, skip_markers=None, content_extractor=None, yield_raw_on_error=True, raw=False):
|
41 |
+
"""Sanitize stream data and extract content."""
|
42 |
+
if skip_markers is None:
|
43 |
+
skip_markers = []
|
44 |
+
|
45 |
+
for chunk in data:
|
46 |
+
if chunk:
|
47 |
+
try:
|
48 |
+
chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else str(chunk)
|
49 |
+
if any(marker in chunk_str for marker in skip_markers):
|
50 |
+
continue
|
51 |
+
|
52 |
+
if to_json:
|
53 |
+
try:
|
54 |
+
json_obj = json.loads(chunk_str)
|
55 |
+
if content_extractor:
|
56 |
+
content = content_extractor(json_obj)
|
57 |
+
if content:
|
58 |
+
yield content
|
59 |
+
except json.JSONDecodeError:
|
60 |
+
if yield_raw_on_error:
|
61 |
+
yield chunk_str
|
62 |
+
else:
|
63 |
+
yield chunk_str
|
64 |
+
except Exception as e:
|
65 |
+
if yield_raw_on_error:
|
66 |
+
yield str(chunk)
|
67 |
+
|
68 |
+
# ============================================================================
|
69 |
+
# Pydantic Models for OpenAI API Compatibility
|
70 |
+
# ============================================================================
|
71 |
+
|
72 |
+
class ChatMessage(BaseModel):
|
73 |
+
role: str = Field(..., description="The role of the message author")
|
74 |
+
content: str = Field(..., description="The content of the message")
|
75 |
+
name: Optional[str] = Field(None, description="The name of the author")
|
76 |
+
|
77 |
+
class ChatCompletionRequest(BaseModel):
|
78 |
+
model: str = Field(..., description="ID of the model to use")
|
79 |
+
messages: List[ChatMessage] = Field(..., description="List of messages comprising the conversation")
|
80 |
+
max_tokens: Optional[int] = Field(2049, description="Maximum number of tokens to generate")
|
81 |
+
temperature: Optional[float] = Field(None, ge=0, le=2, description="Sampling temperature")
|
82 |
+
top_p: Optional[float] = Field(None, ge=0, le=1, description="Nucleus sampling parameter")
|
83 |
+
stream: Optional[bool] = Field(False, description="Whether to stream back partial progress")
|
84 |
+
stop: Optional[Union[str, List[str]]] = Field(None, description="Stop sequences")
|
85 |
+
presence_penalty: Optional[float] = Field(None, ge=-2, le=2, description="Presence penalty")
|
86 |
+
frequency_penalty: Optional[float] = Field(None, ge=-2, le=2, description="Frequency penalty")
|
87 |
+
|
88 |
+
class Usage(BaseModel):
|
89 |
+
prompt_tokens: int
|
90 |
+
completion_tokens: int
|
91 |
+
total_tokens: int
|
92 |
+
|
93 |
+
class ChatCompletionMessage(BaseModel):
|
94 |
+
role: str
|
95 |
+
content: str
|
96 |
+
|
97 |
+
class Choice(BaseModel):
|
98 |
+
index: int
|
99 |
+
message: Optional[ChatCompletionMessage] = None
|
100 |
+
delta: Optional[Dict[str, Any]] = None
|
101 |
+
finish_reason: Optional[str] = None
|
102 |
+
|
103 |
+
class ChatCompletionResponse(BaseModel):
|
104 |
+
id: str
|
105 |
+
object: str = "chat.completion"
|
106 |
+
created: int
|
107 |
+
model: str
|
108 |
+
choices: List[Choice]
|
109 |
+
usage: Usage
|
110 |
+
|
111 |
+
class ChatCompletionChunk(BaseModel):
|
112 |
+
id: str
|
113 |
+
object: str = "chat.completion.chunk"
|
114 |
+
created: int
|
115 |
+
model: str
|
116 |
+
choices: List[Choice]
|
117 |
+
|
118 |
+
class ModelInfo(BaseModel):
|
119 |
+
id: str
|
120 |
+
object: str = "model"
|
121 |
+
created: int
|
122 |
+
owned_by: str = "qodo"
|
123 |
+
|
124 |
+
class ModelListResponse(BaseModel):
|
125 |
+
object: str = "list"
|
126 |
+
data: List[ModelInfo]
|
127 |
+
|
128 |
+
class HealthResponse(BaseModel):
|
129 |
+
status: str
|
130 |
+
timestamp: int
|
131 |
+
|
132 |
+
# ============================================================================
|
133 |
+
# QodoAI Implementation
|
134 |
+
# ============================================================================
|
135 |
+
|
136 |
+
class QodoAI:
|
137 |
+
"""OpenAI-compatible client for Qodo AI API."""
|
138 |
+
|
139 |
+
AVAILABLE_MODELS = [
|
140 |
+
"gpt-4.1",
|
141 |
+
"gpt-4o",
|
142 |
+
"o3",
|
143 |
+
"o4-mini",
|
144 |
+
"claude-4-sonnet",
|
145 |
+
"gemini-2.5-pro"
|
146 |
+
]
|
147 |
+
|
148 |
+
def __init__(self, api_key: Optional[str] = None, timeout: int = 30):
|
149 |
+
self.url = "https://api.cli.qodo.ai/v2/agentic/start-task"
|
150 |
+
self.info_url = "https://api.cli.qodo.ai/v2/info/get-things"
|
151 |
+
self.timeout = timeout
|
152 |
+
self.
|
153 |
+
api_key = api_key or "sk-dS7U-extxMWUxc8SbYYOuncqGUIE8-y2OY8oMCpu0eI-qnSUyH9CYWO_eAMpqwfMo7pXU3QNrclfZYMO0M6BJTM"
|
154 |
+
|
155 |
+
# Generate fingerprint
|
156 |
+
self.fingerprint = {"user_agent": "axios/1.10.0", "browser_type": "chrome"}
|
157 |
+
|
158 |
+
# Generate session ID
|
159 |
+
self.session_id = self._get_session_id()
|
160 |
+
self.request_id = str(uuid.uuid4())
|
161 |
+
|
162 |
+
# Setup headers
|
163 |
+
self.headers = {
|
164 |
+
"Accept": "text/plain",
|
165 |
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
166 |
+
"Accept-Language": "en-US,en;q=0.9",
|
167 |
+
"Authorization": f"Bearer {self.api_key}",
|
168 |
+
"Connection": "close",
|
169 |
+
"Content-Type": "application/json",
|
170 |
+
"host": "api.cli.qodo.ai",
|
171 |
+
"Request-id": self.request_id,
|
172 |
+
"Session-id": self.session_id,
|
173 |
+
"User-Agent": self.fingerprint["user_agent"],
|
174 |
+
}
|
175 |
+
|
176 |
+
# Initialize session
|
177 |
+
self.session = Session()
|
178 |
+
self.session.headers.update(self.headers)
|
179 |
+
|
180 |
+
def _get_session_id(self) -> str:
|
181 |
+
"""Get session ID from Qodo API."""
|
182 |
+
try:
|
183 |
+
temp_session = Session()
|
184 |
+
temp_headers = {
|
185 |
+
"Accept": "text/plain",
|
186 |
+
"Authorization": f"Bearer {self.api_key}",
|
187 |
+
"Content-Type": "application/json",
|
188 |
+
"User-Agent": "axios/1.10.0",
|
189 |
+
}
|
190 |
+
|
191 |
+
temp_session.headers.update(temp_headers)
|
192 |
+
|
193 |
+
response = temp_session.get(self.info_url, timeout=self.timeout, impersonate="chrome110")
|
194 |
+
|
195 |
+
if response.status_code == 200:
|
196 |
+
data = response.json()
|
197 |
+
session_id = data.get("session-id")
|
198 |
+
if session_id:
|
199 |
+
return session_id
|
200 |
+
|
201 |
+
return f"20250630-{str(uuid.uuid4())}"
|
202 |
+
|
203 |
+
except Exception:
|
204 |
+
return f"20250630-{str(uuid.uuid4())}"
|
205 |
+
|
206 |
+
@staticmethod
|
207 |
+
def _qodo_extractor(chunk: Union[str, Dict[str, Any]]) -> Optional[str]:
|
208 |
+
"""Extracts content from Qodo stream JSON objects."""
|
209 |
+
if isinstance(chunk, dict):
|
210 |
+
data = chunk.get("data", {})
|
211 |
+
if isinstance(data, dict):
|
212 |
+
tool_args = data.get("tool_args", {})
|
213 |
+
if isinstance(tool_args, dict):
|
214 |
+
content = tool_args.get("content")
|
215 |
+
if content:
|
216 |
+
return content
|
217 |
+
|
218 |
+
if "content" in data:
|
219 |
+
return data["content"]
|
220 |
+
|
221 |
+
if "choices" in chunk:
|
222 |
+
choices = chunk["choices"]
|
223 |
+
if isinstance(choices, list) and len(choices) > 0:
|
224 |
+
choice = choices[0]
|
225 |
+
if isinstance(choice, dict):
|
226 |
+
delta = choice.get("delta", {})
|
227 |
+
if isinstance(delta, dict) and "content" in delta:
|
228 |
+
return delta["content"]
|
229 |
+
|
230 |
+
message = choice.get("message", {})
|
231 |
+
if isinstance(message, dict) and "content" in message:
|
232 |
+
return message["content"]
|
233 |
+
|
234 |
+
elif isinstance(chunk, str):
|
235 |
+
try:
|
236 |
+
parsed = json.loads(chunk)
|
237 |
+
return QodoAI._qodo_ext
|
238 |
+
ractor(parsed)
|
239 |
+
except json.JSONDecodeError:
|
240 |
+
if chunk.strip():
|
241 |
+
return chunk.strip()
|
242 |
+
|
243 |
+
return None
|
244 |
+
|
245 |
+
def _build_payload(self, prompt: str, model: str = "claude-4-sonnet"):
|
246 |
+
"""Build the payload for Qodo AI API."""
|
247 |
+
return {
|
248 |
+
"agent_type": "cli",
|
249 |
+
"session_id": self.session_id,
|
250 |
+
"user_data": {
|
251 |
+
"extension_version": "0.7.2",
|
252 |
+
"os_platform": "win32",
|
253 |
+
"os_version": "v23.9.0",
|
254 |
+
"editor_type": "cli"
|
255 |
+
},
|
256 |
+
"tools": {
|
257 |
+
"web_search": [
|
258 |
+
{
|
259 |
+
"name": "web_search",
|
260 |
+
"description": "Searches the web and returns results based on the user's query (Powered by Nimble).",
|
261 |
+
"inputSchema": {
|
262 |
+
"type": "object",
|
263 |
+
"properties": {
|
264 |
+
"query": {
|
265 |
+
"description": "The search query to execute",
|
266 |
+
"title": "Query",
|
267 |
+
"type": "string"
|
268 |
+
}
|
269 |
+
},
|
270 |
+
"required": ["query"]
|
271 |
+
},
|
272 |
+
"be_tool": True,
|
273 |
+
"autoApproved": True
|
274 |
+
}
|
275 |
+
]
|
276 |
+
},
|
277 |
+
"user_request": prompt,
|
278 |
+
"execution_strategy": "act",
|
279 |
+
"custom_model": model,
|
280 |
+
"stream": True
|
281 |
+
}
|
282 |
+
|
283 |
+
async def create_chat_completion(self, request: ChatCompletionRequest) -> Union[ChatCompletionResponse, AsyncGenerator]:
|
284 |
+
"""Create a chat completion response."""
|
285 |
+
# Get the last user message
|
286 |
+
user_prompt = ""
|
287 |
+
for message in reversed(request.messages):
|
288 |
+
if message.role == "user":
|
289 |
+
user_prompt = message.content
|
290 |
+
|
291 |
+
break
|
292 |
+
|
293 |
+
if not user_prompt:
|
294 |
+
raise HTTPException(status_code=400, detail="No user message found in messages")
|
295 |
+
|
296 |
+
payload = self._build_payload(user_prompt, request.model)
|
297 |
+
payload["stream"] = request.stream
|
298 |
+
|
299 |
+
request_id = f"chatcmpl-{uuid.uuid4()}"
|
300 |
+
created_time = int(time.time())
|
301 |
+
|
302 |
+
if request.stream:
|
303 |
+
return self._create_stream_response(request_id, created_time, request.model, payload, user_prompt)
|
304 |
+
else:
|
305 |
+
return await self._create_non_stream_response(request_id, created_time, request.model, payload, user_prompt)
|
306 |
+
|
307 |
+
async def _create_stream_response(self, request_id: str, created_time: int, model: str, payload: Dict[str, Any], user_prompt: str):
|
308 |
+
"""Create streaming response."""
|
309 |
+
try:
|
310 |
+
response = self.session.post(
|
311 |
+
self.url,
|
312 |
+
json=payload,
|
313 |
+
stream=True,
|
314 |
+
timeout=self.timeout,
|
315 |
+
impersonate="chrome110"
|
316 |
+
)
|
317 |
+
|
318 |
+
if response.status_code == 401:
|
319 |
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
320 |
+
elif response.status_code != 200:
|
321 |
+
raise HTTPException(status_code=500, detail=f"Qodo request failed: {response.text}")
|
322 |
+
|
323 |
+
async def generate():
|
324 |
+
try:
|
325 |
+
processed_stream = sanitize_stream(
|
326 |
+
data=response.iter_content(chunk_size=None),
|
327 |
+
intro_value="",
|
328 |
+
to_json=True,
|
329 |
+
skip_markers=["[DONE]"],
|
330 |
+
content_extractor=QodoAI._qodo_extractor,
|
331 |
+
yield_raw_on_error=True,
|
332 |
+
raw=False
|
333 |
+
)
|
334 |
+
|
335 |
+
for content_chunk in processed_stream:
|
336 |
+
if content_chunk:
|
337 |
+
chunk_data = {
|
338 |
+
"id": request_id,
|
339 |
+
"object": "chat.completion.chunk",
|
340 |
+
"created": created_time,
|
341 |
+
"model": model,
|
342 |
+
"choices": [{
|
343 |
+
"index": 0,
|
344 |
+
"delta": {"content": content_chunk, "role": "assistant"},
|
345 |
+
"finish_reason": None
|
346 |
+
}]
|
347 |
+
}
|
348 |
+
yield f"data: {json.dumps(chunk_data)}\n\n"
|
349 |
+
|
350 |
+
# Send final chunk
|
351 |
+
final_chunk = {
|
352 |
+
"id": request_id,
|
353 |
+
"object": "chat.completion.chunk",
|
354 |
+
"created": created_time,
|
355 |
+
"model": model,
|
356 |
+
"choices": [{
|
357 |
+
"index": 0,
|
358 |
+
"delta": {},
|
359 |
+
"finish_reason": "stop"
|
360 |
+
}]
|
361 |
+
}
|
362 |
+
yield f"data: {json.dumps(final_chunk)}\n\n"
|
363 |
+
yield "data: [DONE]\n\n"
|
364 |
+
|
365 |
+
except Exception as e:
|
366 |
+
logger.error(f"Streaming error: {e}")
|
367 |
+
error_chunk = {
|
368 |
+
"id": request_id,
|
369 |
+
"object": "chat.completion.chunk",
|
370 |
+
"created": created_time,
|
371 |
+
"model": model,
|
372 |
+
"choices": [{
|
373 |
+
"index": 0,
|
374 |
+
"delta": {},
|
375 |
+
"finish_reason": "stop"
|
376 |
+
}]
|
377 |
+
}
|
378 |
+
yield f"data: {json.dumps(error_chunk)}\n\n"
|
379 |
+
yield "data: [DONE]\n\n"
|
380 |
+
|
381 |
+
return generate()
|
382 |
+
|
383 |
+
except Exception as e:
|
384 |
+
logger.error(f"Stream creation error: {e}")
|
385 |
+
raise HTTPException(status_code=
|
386 |
+
500, detail=str(e))
|
387 |
+
|
388 |
+
async def _create_non_stream_response(self, request_id: str, created_time: int, model: str, payload: Dict[str, Any], user_prompt: str) -> ChatCompletionResponse:
|
389 |
+
"""Create non-streaming response."""
|
390 |
+
try:
|
391 |
+
payload["stream"] = False
|
392 |
+
response = self.session.post(
|
393 |
+
self.url,
|
394 |
+
json=payload,
|
395 |
+
timeout=self.timeout,
|
396 |
+
impersonate="chrome110"
|
397 |
+
)
|
398 |
+
|
399 |
+
if response.status_code == 401:
|
400 |
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
401 |
+
elif response.status_code != 200:
|
402 |
+
raise HTTPException(status_code=500, detail=f"Qodo request failed: {response.text}")
|
403 |
+
|
404 |
+
response_text = response.text
|
405 |
+
full_response = ""
|
406 |
+
|
407 |
+
# Parse multiple JSON objects from the response
|
408 |
+
lines = response_text.replace('}\n{', '}\n{').split('\n')
|
409 |
+
json_objects = []
|
410 |
+
|
411 |
+
current_json = ""
|
412 |
+
brace_count = 0
|
413 |
+
|
414 |
+
for line in lines:
|
415 |
+
line = line.strip()
|
416 |
+
if line:
|
417 |
+
current_json += line
|
418 |
+
brace_count += line.count('{') - line.count('}')
|
419 |
+
|
420 |
+
if brace_count == 0 and current_json:
|
421 |
+
json_objects.append(current_json)
|
422 |
+
current_json = ""
|
423 |
+
|
424 |
+
if current_json and brace_count == 0:
|
425 |
+
json_objects.append(current_json)
|
426 |
+
|
427 |
+
for json_str in json_objects:
|
428 |
+
if json_str.strip():
|
429 |
+
try:
|
430 |
+
json_obj = json.loads(json_str)
|
431 |
+
content = QodoAI._qodo_extractor(json_obj)
|
432 |
+
if content:
|
433 |
+
full_response += content
|
434 |
+
except json.JSONDecodeError:
|
435 |
+
pass
|
436 |
+
|
437 |
+
# Calculate token usage
|
438 |
+
prompt_tokens = len(user_prompt.split())
|
439 |
+
completion_tokens = len(full_response.split())
|
440 |
+
total_tokens = prompt_tokens + completion_tokens
|
441 |
+
|
442 |
+
return ChatCompletionResponse(
|
443 |
+
id=request_id,
|
444 |
+
created=created_time,
|
445 |
+
model=model,
|
446 |
+
choices=[Choice(
|
447 |
+
index=0,
|
448 |
+
message=ChatCompletionMessage(role="assistant", content=full_response),
|
449 |
+
finish_reason="stop"
|
450 |
+
)],
|
451 |
+
usage=Usage(
|
452 |
+
prompt_tokens=prompt_tokens,
|
453 |
+
completion_tokens=completion_tokens,
|
454 |
+
total_tokens=total_tokens
|
455 |
+
)
|
456 |
+
)
|
457 |
+
|
458 |
+
except Exception as e:
|
459 |
+
logger.error(f"Non-stream response error: {e}")
|
460 |
+
raise HTTPException(status_code=500, detail=str(e))
|
461 |
+
|
462 |
+
# ============================================================================
|
463 |
+
# FastAPI Application
|
464 |
+
# ============================================================================
|
465 |
+
|
466 |
+
# Global QodoAI client
|
467 |
+
qodo_client = None
|
468 |
+
|
469 |
+
@asynccontextmanager
|
470 |
+
async def lifespan(app: FastAPI):
|
471 |
+
"""Application lifespan manager."""
|
472 |
+
global qodo_client
|
473 |
+
logger.info("Starting FastAPI application...")
|
474 |
+
qodo_client = QodoAI()
|
475 |
+
yield
|
476 |
+
logger.info("Shutting down FastAPI application...")
|
477 |
+
|
478 |
+
# Create FastAPI app
|
479 |
+
app = FastAPI(
|
480 |
+
title="QodoAI OpenAI-Compatible API",
|
481 |
+
description="FastAPI application providing OpenAI-compatible endpoints using QodoAI",
|
482 |
+
version="1.0.0",
|
483 |
+
lifespan=lifespan
|
484 |
+
)
|
485 |
+
|
486 |
+
# Add CORS middleware
|
487 |
+
app.add_middleware(
|
488 |
+
CORSMiddleware,
|
489 |
+
allow_origins=["*"],
|
490 |
+
allow_credentials=True,
|
491 |
+
allow_methods=["*"],
|
492 |
+
|
493 |
+
allow_headers=["*"],
|
494 |
+
)
|
495 |
+
|
496 |
+
# Authentication dependency
|
497 |
+
async def verify_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
498 |
+
"""Verify API key from Authorization header."""
|
499 |
+
if not credentials:
|
500 |
+
raise HTTPException(
|
501 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
502 |
+
detail="Missing API key",
|
503 |
+
headers={"WWW-Authenticate": "Bearer"},
|
504 |
+
)
|
505 |
+
|
506 |
+
# In a production environment, you would validate the API key here
|
507 |
+
# For now, we accept any non-empty key
|
508 |
+
if not credentials.credentials:
|
509 |
+
raise HTTPException(
|
510 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
511 |
+
detail="Invalid API key",
|
512 |
+
headers={"WWW-Authenticate": "Bearer"},
|
513 |
+
)
|
514 |
+
|
515 |
+
return credentials.credentials
|
516 |
+
|
517 |
+
# ============================================================================
|
518 |
+
# API Endpoints
|
519 |
+
# ============================================================================
|
520 |
+
|
521 |
+
@app.get("/health", response_model=HealthResponse)
|
522 |
+
async def health_check():
|
523 |
+
"""Health check endpoint."""
|
524 |
+
return HealthResponse(
|
525 |
+
status="healthy",
|
526 |
+
timestamp=int(time.time())
|
527 |
+
)
|
528 |
+
|
529 |
+
@app.get("/v1/models", response_model=ModelListResponse)
|
530 |
+
async def list_models(api_key: str = Depends(verify_api_key)):
|
531 |
+
"""List available models."""
|
532 |
+
try:
|
533 |
+
models = []
|
534 |
+
for model_id in QodoAI.AVAILABLE_MODELS:
|
535 |
+
models.append(ModelInfo(
|
536 |
+
id=model_id,
|
537 |
+
created=int(time.time()),
|
538 |
+
owned_by="qodo"
|
539 |
+
))
|
540 |
+
|
541 |
+
return ModelListResponse(data=models)
|
542 |
+
|
543 |
+
except Exception as e:
|
544 |
+
logger.error(f"Error listing models: {e}")
|
545 |
+
raise HTTPException(status_code=500, detail="Failed to list models")
|
546 |
+
|
547 |
+
@app.post("/v1/chat/completions")
|
548 |
+
async def create_chat_completion(
|
549 |
+
request: ChatCompletion
|
550 |
+
Request,
|
551 |
+
api_key: str = Depends(verify_api_key)
|
552 |
+
):
|
553 |
+
"""Create a chat completion."""
|
554 |
+
try:
|
555 |
+
# Validate model
|
556 |
+
if request.model not in QodoAI.AVAILABLE_MODELS:
|
557 |
+
raise HTTPException(
|
558 |
+
status_code=400,
|
559 |
+
detail=f"Model '{request.model}' is not available. Available models: {QodoAI.AVAILABLE_MODELS}"
|
560 |
+
)
|
561 |
+
|
562 |
+
# Create chat completion
|
563 |
+
result = await qodo_client.create_chat_completion(request)
|
564 |
+
|
565 |
+
if request.stream:
|
566 |
+
# Return streaming response
|
567 |
+
return StreamingResponse(
|
568 |
+
result,
|
569 |
+
media_type="text/plain",
|
570 |
+
headers={
|
571 |
+
"Cache-Control": "no-cache",
|
572 |
+
"Connection": "keep-alive",
|
573 |
+
"Content-Type": "text/plain; charset=utf-8"
|
574 |
+
}
|
575 |
+
)
|
576 |
+
else:
|
577 |
+
# Return non-streaming response
|
578 |
+
return result
|
579 |
+
|
580 |
+
except HTTPException:
|
581 |
+
raise
|
582 |
+
except Exception as e:
|
583 |
+
logger.error(f"Error creating chat completion: {e}")
|
584 |
+
raise HTTPException(status_code=500, detail="Failed to create chat completion")
|
585 |
+
|
586 |
+
@app.exception_handler(Exception)
|
587 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
588 |
+
"""Global exception handler."""
|
589 |
+
logger.error(f"Unhandled exception: {exc}")
|
590 |
+
return JSONResponse(
|
591 |
+
status_code=500,
|
592 |
+
content={"error": {"message": "Internal server error", "type": "internal_error"}}
|
593 |
+
)
|
594 |
+
|
595 |
+
@app.middleware("http")
|
596 |
+
async def log_requests(request: Request, call_next):
|
597 |
+
"""Log all requests."""
|
598 |
+
start_time = time.time()
|
599 |
+
|
600 |
+
# Log request
|
601 |
+
logger.info(f"{request.method} {request.url.path} - Start")
|
602 |
+
|
603 |
+
response
|
604 |
+
= await call_next(request)
|
605 |
+
|
606 |
+
# Log response
|
607 |
+
process_time = time.time() - start_time
|
608 |
+
logger.info(f"{request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s")
|
609 |
+
|
610 |
+
return response
|
611 |
+
|
612 |
+
# ============================================================================
|
613 |
+
# Main Application Entry Point
|
614 |
+
# ============================================================================
|
615 |
+
|
616 |
+
if __name__ == "__main__":
|
617 |
+
uvicorn.run(
|
618 |
+
"main:app",
|
619 |
+
host="0.0.0.0",
|
620 |
+
port=8000,
|
621 |
+
reload=True,
|
622 |
+
log_level="info"
|
623 |
+
)
|