File size: 13,055 Bytes
fcc1420
 
 
 
29dc2a3
fcc1420
 
 
 
 
 
 
 
 
 
 
29dc2a3
 
 
fcc1420
29dc2a3
fcc1420
 
 
 
 
 
 
 
 
 
 
29dc2a3
 
 
fcc1420
 
29dc2a3
 
 
 
 
 
 
fcc1420
 
 
 
29dc2a3
 
 
fcc1420
29dc2a3
 
fcc1420
 
 
 
 
29dc2a3
fcc1420
29dc2a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcc1420
 
 
29dc2a3
fcc1420
29dc2a3
 
 
 
 
 
 
 
 
 
 
fcc1420
 
 
 
 
 
 
 
 
29dc2a3
 
 
 
 
fcc1420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29dc2a3
 
fcc1420
 
 
 
 
 
 
 
 
 
 
 
29dc2a3
fcc1420
 
 
 
 
 
 
 
 
 
29dc2a3
 
 
 
 
 
 
fcc1420
 
 
 
 
 
29dc2a3
fcc1420
 
 
 
 
 
 
 
 
 
 
 
29dc2a3
fcc1420
29dc2a3
 
 
 
 
 
 
 
 
 
 
 
 
 
fcc1420
29dc2a3
fcc1420
29dc2a3
fcc1420
 
29dc2a3
 
fcc1420
 
29dc2a3
fcc1420
29dc2a3
fcc1420
 
 
 
 
 
29dc2a3
fcc1420
 
29dc2a3
fcc1420
 
 
 
 
 
 
 
 
 
29dc2a3
fcc1420
 
 
29dc2a3
 
fcc1420
29dc2a3
fcc1420
 
 
 
 
 
 
29dc2a3
 
fcc1420
 
 
 
29dc2a3
fcc1420
 
 
 
 
 
 
 
 
 
29dc2a3
 
fcc1420
 
 
 
 
 
 
 
29dc2a3
fcc1420
 
29dc2a3
 
 
 
 
 
 
 
 
fcc1420
29dc2a3
fcc1420
 
29dc2a3
 
 
 
 
fcc1420
 
 
 
 
29dc2a3
 
fcc1420
 
 
 
 
 
 
 
 
 
29dc2a3
fcc1420
29dc2a3
fcc1420
 
 
 
 
 
 
 
 
 
 
 
29dc2a3
 
 
fcc1420
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
import os
import json
import time
import uuid
import sys
from typing import List, Dict, Optional, Union, Generator, Any

# --- Core Dependencies ---
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from curl_cffi.requests import Session
from curl_cffi import CurlError

# --- Environment Configuration ---
QODO_API_KEY = os.getenv("QODO_API_KEY") # No default key to encourage setting it explicitly
QODO_URL = os.getenv("QODO_URL", "https://*")
QODO_INFO_URL = os.getenv("QODO_INFO_URL", "*")

# --- Recreated/Mocked webscout & OpenAI Dependencies ---
# This section recreates the necessary classes and functions
# to make the QodoAI provider self-contained.

class exceptions:
    class FailedToGenerateResponseError(Exception):
        pass

def sanitize_stream(data: Generator[bytes, None, None], content_extractor: callable, **kwargs: Any) -> Generator[str, None, None]:
    buffer = ""
    for byte_chunk in data:
        buffer += byte_chunk.decode('utf-8', errors='ignore')
        obj_start_indices = [i for i, char in enumerate(buffer) if char == '{']
        if not obj_start_indices:
            continue
        
        start_index = 0
        for obj_start in obj_start_indices:
            if obj_start < start_index:
                continue
            
            brace_count = 0
            obj_end = -1
            for i in range(obj_start, len(buffer)):
                if buffer[i] == '{':
                    brace_count += 1
                elif buffer[i] == '}':
                    brace_count -= 1
                if brace_count == 0:
                    obj_end = i
                    break
            
            if obj_end != -1:
                json_str = buffer[obj_start:obj_end + 1]
                try:
                    json_obj = json.loads(json_str)
                    content = content_extractor(json_obj)
                    if content:
                        yield content
                    start_index = obj_end + 1
                except json.JSONDecodeError:
                    continue
        buffer = buffer[start_index:]

# --- OpenAI-Compatible Pydantic Models ---

# Request Models
class ChatMessage(BaseModel):
    role: str
    content: str
    name: Optional[str] = None
    tool_calls: Optional[List[Dict]] = None
    tool_call_id: Optional[str] = None

class Function(BaseModel):
    name: str
    description: Optional[str] = None
    parameters: Dict[str, Any]

class Tool(BaseModel):
    type: str = "function"
    function: Function

class ChatCompletionRequest(BaseModel):
    model: str
    messages: List[ChatMessage]
    max_tokens: Optional[int] = 2049
    stream: bool = False
    temperature: Optional[float] = 1.0
    top_p: Optional[float] = 1.0
    tools: Optional[List[Tool]] = None
    tool_choice: Optional[Union[str, Dict]] = None

# Response Models
class ChatCompletionMessage(BaseModel):
    role: str
    content: Optional[str] = None
    tool_calls: Optional[List[Dict]] = None

class ChoiceDelta(BaseModel):
    content: Optional[str] = None
    role: Optional[str] = None

class Choice(BaseModel):
    index: int
    message: ChatCompletionMessage
    finish_reason: Optional[str] = "stop"

class ChoiceStreaming(BaseModel):
    index: int
    delta: ChoiceDelta
    finish_reason: Optional[str] = None

class CompletionUsage(BaseModel):
    prompt_tokens: int
    completion_tokens: int
    total_tokens: int

class ChatCompletion(BaseModel):
    id: str
    choices: List[Choice]
    created: int
    model: str
    object: str = "chat.completion"
    usage: CompletionUsage

class ChatCompletionChunk(BaseModel):
    id: str
    choices: List[ChoiceStreaming]
    created: int
    model: str
    object: str = "chat.completion.chunk"
    usage: Optional[CompletionUsage] = None


# --- Base Provider Structure ---
class BaseCompletions:
    def __init__(self, client: Any):
        self._client = client

class BaseChat:
    def __init__(self, client: Any):
        self.completions = Completions(client)

class OpenAICompatibleProvider:
    def __init__(self, **kwargs: Any):
        pass

# --- QodoAI Provider Logic ---

class Completions(BaseCompletions):
    def create(
        self,
        *,
        model: str,
        messages: List[Dict[str, Any]],
        stream: bool = False,
        **kwargs: Any
    ) -> Union[ChatCompletion, Generator[ChatCompletionChunk, None, None]]:
        
        # Warn about unsupported parameters
        unsupported_params = ['temperature', 'top_p', 'tools', 'tool_choice', 'max_tokens']
        for param in unsupported_params:
            if param in kwargs:
                print(f"Warning: Parameter '{param}' is not supported by the QodoAI provider and will be ignored.", file=sys.stderr)

        user_prompt = ""
        for message in reversed(messages):
            if message.get("role") == "user":
                user_prompt = message.get("content", "")
                break
        if not user_prompt:
            raise ValueError("No user message with 'role': 'user' found in messages.")

        payload = self._client._build_payload(user_prompt, model)
        payload["stream"] = stream

        request_id = f"chatcmpl-{uuid.uuid4()}"
        created_time = int(time.time())

        if stream:
            return self._create_stream(request_id, created_time, model, payload, user_prompt)
        else:
            return self._create_non_stream(request_id, created_time, model, payload, user_prompt)

    def _create_stream(self, request_id, created_time, model, payload, user_prompt) -> Generator[ChatCompletionChunk, None, None]:
        try:
            with self._client.session.post(self._client.url, json=payload, stream=True, timeout=self._client.timeout, impersonate="chrome110") as response:
                if response.status_code == 401:
                    raise exceptions.FailedToGenerateResponseError("Invalid Qodo API key provided.")
                response.raise_for_status()

                for content_chunk in sanitize_stream(response.iter_content(chunk_size=8192), QodoAI._qodo_extractor):
                    if content_chunk:
                        delta = ChoiceDelta(content=content_chunk, role="assistant")
                        choice = ChoiceStreaming(index=0, delta=delta, finish_reason=None)
                        yield ChatCompletionChunk(id=request_id, choices=[choice], created=created_time, model=model)
                
                final_delta = ChoiceDelta()
                final_choice = ChoiceStreaming(index=0, delta=final_delta, finish_reason="stop")
                yield ChatCompletionChunk(id=request_id, choices=[final_choice], created=created_time, model=model)
        except Exception as e:
            raise exceptions.FailedToGenerateResponseError(f"Stream generation failed: {e}")

    def _create_non_stream(self, request_id, created_time, model, payload, user_prompt) -> ChatCompletion:
        try:
            payload["stream"] = False
            response = self._client.session.post(self._client.url, json=payload, timeout=self._client.timeout, impersonate="chrome110")

            if response.status_code == 401:
                raise exceptions.FailedToGenerateResponseError("Invalid Qodo API key provided.")
            response.raise_for_status()

            full_response = "".join(list(sanitize_stream(iter([response.content]), QodoAI._qodo_extractor)))

            prompt_tokens = len(user_prompt.split())
            completion_tokens = len(full_response.split())

            message = ChatCompletionMessage(role="assistant", content=full_response)
            choice = Choice(index=0, message=message, finish_reason="stop")
            usage = CompletionUsage(prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, total_tokens=prompt_tokens + completion_tokens)
            return ChatCompletion(id=request_id, choices=[choice], created=created_time, model=model, usage=usage)
        except Exception as e:
            raise exceptions.FailedToGenerateResponseError(f"Non-stream generation failed: {e}")

class Chat(BaseChat):
    def __init__(self, client: 'QodoAI'):
        self.completions = Completions(client)

class QodoAI(OpenAICompatibleProvider):
    AVAILABLE_MODELS = ["gpt-4.1", "gpt-4o", "o3", "o4-mini", "claude-4-sonnet", "gemini-2.5-pro"]

    def __init__(self, api_key: str, **kwargs: Any):
        super().__init__(api_key=api_key, **kwargs)
        self.url, self.info_url, self.timeout, self.api_key = QODO_URL, QODO_INFO_URL, 600, api_key
        self.user_agent = "axios/1.10.0"
        self.session_id = self._get_session_id()
        self.headers = {
            "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json",
            "User-Agent": self.user_agent, "Session-id": self.session_id
        }
        self.session = Session(headers=self.headers)
        self.chat = Chat(self)
        
    @staticmethod
    def _qodo_extractor(chunk: Union[str, Dict[str, Any]]) -> Optional[str]:
        if isinstance(chunk, dict):
            data = chunk.get("data", {})
            if isinstance(data, dict):
                content = data.get("content") or (data.get("tool_args", {}) or {}).get("content")
                if content: return content
        return None

    def _get_session_id(self) -> str:
        try:
            response = Session(headers={"Authorization": f"Bearer {self.api_key}"}).get(self.info_url, timeout=self.timeout)
            if response.status_code == 200:
                return response.json().get("session-id", f"fallback-{uuid.uuid4()}")
            elif response.status_code == 401:
                raise exceptions.FailedToGenerateResponseError("Invalid Qodo API key. Please check your QODO_API_KEY environment variable.")
            else:
                raise exceptions.FailedToGenerateResponseError(f"Failed to get session_id from Qodo: HTTP {response.status_code}")
        except Exception as e:
            raise exceptions.FailedToGenerateResponseError(f"Failed to connect to Qodo API to get session_id: {e}")

    def _build_payload(self, prompt: str, model: str) -> Dict[str, Any]:
        return {"agent_type": "cli", "session_id": self.session_id, "user_request": prompt, "custom_model": model, "stream": True}

# --- FastAPI Application ---

app = FastAPI(
    title="QodoAI OpenAI-Compatible API",
    description="Provides an OpenAI-compatible interface for the QodoAI service.",
    version="1.0.0"
)

client: Optional[QodoAI] = None

@app.on_event("startup")
def startup_event():
    global client
    if not QODO_API_KEY:
        raise RuntimeError("QODO_API_KEY environment variable not set. The server cannot start without an API key.")
    try:
        client = QodoAI(api_key=QODO_API_KEY)
        print("QodoAI client initialized successfully.")
    except exceptions.FailedToGenerateResponseError as e:
        raise RuntimeError(f"FATAL: Could not initialize QodoAI client: {e}")

@app.get("/v1/models", response_model_exclude_none=True)
async def list_models():
    """Lists the available models from the QodoAI provider."""
    models_data = [
        {"id": model_id, "object": "model", "created": int(time.time()), "owned_by": "qodoai"}
        for model_id in QodoAI.AVAILABLE_MODELS
    ]
    return {"object": "list", "data": models_data}

@app.post("/v1/chat/completions")
async def create_chat_completion(request: ChatCompletionRequest):
    """Creates a chat completion, supporting both streaming and non-streaming modes."""
    if client is None:
        raise HTTPException(status_code=503, detail="QodoAI client is not available or failed to initialize.")
    
    params = request.model_dump(exclude_none=True)
    
    try:
        if request.stream:
            async def stream_generator():
                try:
                    generator = client.chat.completions.create(**params)
                    for chunk in generator:
                        yield f"data: {chunk.model_dump_json()}\n\n"
                except exceptions.FailedToGenerateResponseError as e:
                    error_payload = {"error": {"message": str(e), "type": "api_error", "code": 500}}
                    yield f"data: {json.dumps(error_payload)}\n\n"
                finally:
                    yield "data: [DONE]\n\n"
            return StreamingResponse(stream_generator(), media_type="text/event-stream")
        else:
            response = client.chat.completions.create(**params)
            return JSONResponse(content=response.model_dump())

    except exceptions.FailedToGenerateResponseError as e:
        raise HTTPException(status_code=500, detail=str(e))
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

if __name__ == "__main__":
    if not QODO_API_KEY:
        print("Error: The QODO_API_KEY environment variable must be set.", file=sys.stderr)
        sys.exit(1)
    uvicorn.run(app, host="0.0.0.0", port=8000)