Shantanupathak94 commited on
Commit
58d53ff
Β·
1 Parent(s): 6268fd4

recovering old backend code

Browse files
Files changed (2) hide show
  1. ai_gateway.py +483 -0
  2. main.py +1059 -168
ai_gateway.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # Ethrix-Forge β€” Agentic AI Gateway
3
+ # Drop-in module for main.py (FastAPI + google-genai + httpx)
4
+ # Author-ready: copy the imports + constants + functions below
5
+ # ============================================================
6
+
7
+ # ── REQUIRED IMPORTS (merge into your existing main.py imports) ──────────────
8
+ import os
9
+ import json
10
+ import asyncio
11
+ import logging
12
+ from typing import Any
13
+
14
+ import httpx
15
+ from google import genai # pip install google-genai
16
+ from google.genai import types as genai_types
17
+ from fastapi import HTTPException
18
+ from pydantic import BaseModel
19
+
20
+ # ── LOGGING ──────────────────────────────────────────────────────────────────
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger("ethrix_forge.ai_gateway")
23
+
24
+
25
+ # ══════════════════════════════════════════════════════════════════════════════
26
+ # PYDANTIC REQUEST / RESPONSE SCHEMAS
27
+ # ══════════════════════════════════════════════════════════════════════════════
28
+
29
+ class ExistingFile(BaseModel):
30
+ """A file already open in the editor that the AI can read as context."""
31
+ filename: str
32
+ language: str
33
+ code: str
34
+
35
+
36
+ class AgentRequest(BaseModel):
37
+ """
38
+ POST body your React frontend must send to /api/agent/generate
39
+ {
40
+ "prompt": "Add a dark-mode toggle to the Navbar",
41
+ "existing_files": [
42
+ {"filename": "index.html", "language": "html", "code": "..."},
43
+ {"filename": "style.css", "language": "css", "code": "..."}
44
+ ],
45
+ "model_preference": "gemini" // optional: "gemini" | "openrouter" | "groq"
46
+ }
47
+ """
48
+ prompt: str
49
+ existing_files: list[ExistingFile] = []
50
+ model_preference: str = "gemini"
51
+
52
+
53
+ class GeneratedFile(BaseModel):
54
+ filename: str
55
+ language: str
56
+ code: str
57
+
58
+
59
+ class AgentResponse(BaseModel):
60
+ files: list[GeneratedFile]
61
+ provider_used: str # which provider actually answered
62
+ total_files_changed: int
63
+
64
+
65
+ # ══════════════════════════════════════════════════════════════════════════════
66
+ # SYSTEM PROMPT (the "brain" of the agentic workflow)
67
+ # ══════════════════════════════════════════════════════════════════════════════
68
+
69
+ AGENTIC_SYSTEM_PROMPT = """
70
+ You are Ethrix, an elite AI Software Architect and Senior Full-Stack Developer
71
+ embedded inside the Ethrix-Forge Cloud Code Editor.
72
+
73
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
74
+ WORKFLOW (Think β†’ Plan β†’ Execute)
75
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
76
+
77
+ STEP 1 – THINK (internal, do not output)
78
+ β€’ Re-read the user's request and ALL existing files carefully.
79
+ β€’ Understand the full project structure, dependencies, and intent.
80
+ β€’ Identify EXACTLY which files need to be created or modified.
81
+
82
+ STEP 2 – PLAN (internal, do not output)
83
+ β€’ List the minimal set of files that must change.
84
+ β€’ Never touch files that are unaffected by the request.
85
+ β€’ If a new file is needed (e.g., a component, a utility), create it.
86
+
87
+ STEP 3 – EXECUTE (this is your ONLY output)
88
+ β€’ Respond with a VALID JSON array β€” nothing else.
89
+ β€’ No markdown code fences, no explanations, no preamble.
90
+ β€’ Each element in the array is an object with exactly these three keys:
91
+ "filename" β€” relative path e.g. "components/Navbar.js"
92
+ "language" β€” lowercase language id e.g. "javascript", "python", "css"
93
+ "code" β€” the complete, production-ready file content as a string
94
+
95
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
96
+ STRICT RULES
97
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
98
+ 1. OUTPUT FORMAT: Your entire response MUST be a JSON array.
99
+ βœ… CORRECT: [ {"filename": "...", "language": "...", "code": "..."} ]
100
+ ❌ WRONG: Any text, markdown, or explanation outside the JSON array.
101
+
102
+ 2. SELECTIVE EDITING: Only include files you actually changed or created.
103
+ If index.html is unchanged, DO NOT include it in your response.
104
+
105
+ 3. COMPLETE FILES: Each "code" value must contain the full file content β€”
106
+ never use placeholders like "// rest of code here".
107
+
108
+ 4. FOLDER SUPPORT: Use forward-slash paths for nested files:
109
+ "src/components/Button.jsx", "assets/css/theme.css"
110
+
111
+ 5. LANGUAGE IDS: Use standard lowercase identifiers:
112
+ html, css, javascript, typescript, python, json, markdown, etc.
113
+
114
+ 6. QUALITY: Write production-grade code. Use modern best practices,
115
+ clean variable names, and add brief inline comments where helpful.
116
+ """.strip()
117
+
118
+
119
+ # ══════════════════════════════════════════════════════════════════════════════
120
+ # PROVIDER CONFIG β€” loaded from environment variables
121
+ # ══════════════════════════════════════════════════════════════════════════════
122
+
123
+ def _get_gemini_keys() -> list[str]:
124
+ """
125
+ Collect all GEMINI_API_KEY, GEMINI_API_KEY_2, GEMINI_API_KEY_3, …
126
+ from environment variables in order.
127
+ """
128
+ keys: list[str] = []
129
+ # Primary key
130
+ primary = os.getenv("GEMINI_API_KEY", "")
131
+ if primary:
132
+ keys.append(primary)
133
+ # Numbered backups: GEMINI_API_KEY_2 … GEMINI_API_KEY_10
134
+ for i in range(2, 11):
135
+ k = os.getenv(f"GEMINI_API_KEY_{i}", "")
136
+ if k:
137
+ keys.append(k)
138
+ return keys
139
+
140
+
141
+ OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
142
+ GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
143
+
144
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
145
+ OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat-v3-0324:free")
146
+ GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
147
+
148
+ # Errors that should trigger a key / provider switch
149
+ _RATE_LIMIT_PHRASES = (
150
+ "429",
151
+ "resource_exhausted",
152
+ "rate limit",
153
+ "quota exceeded",
154
+ "too many requests",
155
+ )
156
+
157
+
158
+ # ══════════════════════════════════════════════════════════════════════════════
159
+ # PROMPT BUILDER β€” assembles the full user message with context
160
+ # ══════════════════════════════════════════════════════════════════════════════
161
+
162
+ def _build_user_message(prompt: str, existing_files: list[ExistingFile]) -> str:
163
+ """
164
+ Wraps the user's instruction with the current file context so the AI
165
+ understands what already exists before making changes.
166
+ """
167
+ parts: list[str] = []
168
+
169
+ if existing_files:
170
+ parts.append("=== EXISTING PROJECT FILES (read-only context) ===\n")
171
+ for f in existing_files:
172
+ parts.append(
173
+ f"FILE: {f.filename} [language: {f.language}]\n"
174
+ f"```{f.language}\n{f.code}\n```\n"
175
+ )
176
+ parts.append("=== END OF EXISTING FILES ===\n\n")
177
+
178
+ parts.append(f"USER REQUEST:\n{prompt}")
179
+ return "\n".join(parts)
180
+
181
+
182
+ # ══════════════════════════════════════════════════════════════════════════════
183
+ # JSON PARSER β€” robust extraction from raw LLM text
184
+ # ══════════════════════════════════════════════════════════════════════════════
185
+
186
+ def _parse_files_from_response(raw: str) -> list[dict]:
187
+ """
188
+ Tries multiple strategies to extract the JSON array from LLM output.
189
+ Strategy 1 β€” direct json.loads (ideal: model obeyed instructions).
190
+ Strategy 2 β€” find the first '[' … last ']' and parse that slice.
191
+ Strategy 3 β€” strip markdown fences then retry.
192
+ """
193
+ text = raw.strip()
194
+
195
+ # Strategy 1: Direct parse
196
+ try:
197
+ data = json.loads(text)
198
+ if isinstance(data, list):
199
+ return data
200
+ except json.JSONDecodeError:
201
+ pass
202
+
203
+ # Strategy 2: Bracket slicing
204
+ start = text.find("[")
205
+ end = text.rfind("]")
206
+ if start != -1 and end != -1 and end > start:
207
+ try:
208
+ data = json.loads(text[start : end + 1])
209
+ if isinstance(data, list):
210
+ logger.warning("JSON extracted via bracket-slice strategy.")
211
+ return data
212
+ except json.JSONDecodeError:
213
+ pass
214
+
215
+ # Strategy 3: Strip markdown code fences
216
+ import re
217
+ cleaned = re.sub(r"```(?:json)?", "", text).strip()
218
+ try:
219
+ data = json.loads(cleaned)
220
+ if isinstance(data, list):
221
+ logger.warning("JSON extracted after stripping markdown fences.")
222
+ return data
223
+ except json.JSONDecodeError:
224
+ pass
225
+
226
+ logger.error("All JSON parse strategies failed. Raw response:\n%s", raw[:2000])
227
+ raise ValueError("AI response could not be parsed as a JSON array of files.")
228
+
229
+
230
+ # ══════════════════════════════════════════════════════════════════════════════
231
+ # PROVIDER CALLS
232
+ # ══════════════════════════════════════════════════════════════════════════════
233
+
234
+ def _is_rate_limit_error(exc: Exception) -> bool:
235
+ msg = str(exc).lower()
236
+ return any(phrase in msg for phrase in _RATE_LIMIT_PHRASES)
237
+
238
+
239
+ # ── Gemini ────────────────────────────────────────────────────────────────────
240
+
241
+ def _sync_call_gemini(api_key: str, user_message: str) -> str:
242
+ """
243
+ Synchronous Gemini call using the new google-genai SDK.
244
+ Wrapped with asyncio.to_thread() at the call site so FastAPI never blocks.
245
+ """
246
+ client = genai.Client(api_key=api_key)
247
+ response = client.models.generate_content(
248
+ model=GEMINI_MODEL,
249
+ contents=user_message,
250
+ config=genai_types.GenerateContentConfig(
251
+ system_instruction=AGENTIC_SYSTEM_PROMPT,
252
+ temperature=0.2, # lower = more deterministic / structured
253
+ max_output_tokens=8192,
254
+ ),
255
+ )
256
+ return response.text
257
+
258
+
259
+ async def _call_gemini_with_fallback(user_message: str) -> tuple[str, str]:
260
+ """
261
+ Tries every available Gemini API key in sequence.
262
+ Returns (raw_text, key_label) or raises RuntimeError if all keys fail.
263
+ """
264
+ keys = _get_gemini_keys()
265
+ if not keys:
266
+ raise RuntimeError("No GEMINI_API_KEY found in environment.")
267
+
268
+ last_exc: Exception | None = None
269
+ for idx, key in enumerate(keys):
270
+ key_label = "GEMINI_API_KEY" if idx == 0 else f"GEMINI_API_KEY_{idx + 1}"
271
+ try:
272
+ logger.info("Trying Gemini with %s …", key_label)
273
+ text = await asyncio.to_thread(_sync_call_gemini, key, user_message)
274
+ logger.info("Gemini (%s) succeeded.", key_label)
275
+ return text, key_label
276
+ except Exception as exc:
277
+ last_exc = exc
278
+ if _is_rate_limit_error(exc):
279
+ logger.warning(
280
+ "%s hit rate/quota limit (%s). Switching to next key …",
281
+ key_label, exc
282
+ )
283
+ continue # try next key
284
+ else:
285
+ logger.error("Gemini (%s) non-retryable error: %s", key_label, exc)
286
+ raise # hard failure β€” don't retry other keys
287
+
288
+ raise RuntimeError(
289
+ f"All Gemini keys exhausted. Last error: {last_exc}"
290
+ )
291
+
292
+
293
+ # ── OpenRouter ────────────────────────────────────────────────────────────────
294
+
295
+ async def _call_openrouter(user_message: str) -> str:
296
+ """Async OpenRouter call via httpx."""
297
+ if not OPENROUTER_API_KEY:
298
+ raise RuntimeError("OPENROUTER_API_KEY not configured.")
299
+
300
+ payload = {
301
+ "model": OPENROUTER_MODEL,
302
+ "messages": [
303
+ {"role": "system", "content": AGENTIC_SYSTEM_PROMPT},
304
+ {"role": "user", "content": user_message},
305
+ ],
306
+ "temperature": 0.2,
307
+ "max_tokens": 8192,
308
+ }
309
+ headers = {
310
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
311
+ "Content-Type": "application/json",
312
+ "HTTP-Referer": "https://ethrix-forge.app", # your app URL
313
+ "X-Title": "Ethrix-Forge",
314
+ }
315
+
316
+ async with httpx.AsyncClient(timeout=90) as client:
317
+ resp = client.post(
318
+ "https://openrouter.ai/api/v1/chat/completions",
319
+ json=payload,
320
+ headers=headers,
321
+ )
322
+ resp = await resp if asyncio.iscoroutine(resp) else resp # compatibility shim
323
+ resp.raise_for_status()
324
+ data = resp.json()
325
+ return data["choices"][0]["message"]["content"]
326
+
327
+
328
+ async def _call_openrouter_safe(user_message: str) -> tuple[str, str]:
329
+ logger.info("Trying OpenRouter (%s) …", OPENROUTER_MODEL)
330
+ text = await _call_openrouter(user_message)
331
+ logger.info("OpenRouter succeeded.")
332
+ return text, f"openrouter/{OPENROUTER_MODEL}"
333
+
334
+
335
+ # ── Groq ──────────────────────────────────────────────────────────────────────
336
+
337
+ async def _call_groq(user_message: str) -> str:
338
+ """Async Groq call via httpx."""
339
+ if not GROQ_API_KEY:
340
+ raise RuntimeError("GROQ_API_KEY not configured.")
341
+
342
+ payload = {
343
+ "model": GROQ_MODEL,
344
+ "messages": [
345
+ {"role": "system", "content": AGENTIC_SYSTEM_PROMPT},
346
+ {"role": "user", "content": user_message},
347
+ ],
348
+ "temperature": 0.2,
349
+ "max_tokens": 8192,
350
+ }
351
+ headers = {
352
+ "Authorization": f"Bearer {GROQ_API_KEY}",
353
+ "Content-Type": "application/json",
354
+ }
355
+
356
+ async with httpx.AsyncClient(timeout=90) as client:
357
+ resp = await client.post(
358
+ "https://api.groq.com/openai/v1/chat/completions",
359
+ json=payload,
360
+ headers=headers,
361
+ )
362
+ resp.raise_for_status()
363
+ data = resp.json()
364
+ return data["choices"][0]["message"]["content"]
365
+
366
+
367
+ async def _call_groq_safe(user_message: str) -> tuple[str, str]:
368
+ logger.info("Trying Groq (%s) …", GROQ_MODEL)
369
+ text = await _call_groq(user_message)
370
+ logger.info("Groq succeeded.")
371
+ return text, f"groq/{GROQ_MODEL}"
372
+
373
+
374
+ # ══════════════════════════════════════════════════════════════════════════════
375
+ # MASTER ORCHESTRATOR β€” Think β†’ Plan β†’ Execute with full fallback chain
376
+ # ══════════════════════════════════════════════════════════════════════════════
377
+
378
+ async def run_agentic_workflow(request: AgentRequest) -> AgentResponse:
379
+ """
380
+ Core agentic pipeline:
381
+ 1. Build the context-aware user message.
382
+ 2. Try providers in preferred order with automatic fallback.
383
+ 3. Parse the JSON response into structured GeneratedFile objects.
384
+ 4. Return only the files that were actually changed / created.
385
+ """
386
+ user_message = _build_user_message(request.prompt, request.existing_files)
387
+
388
+ # ── Build the provider call order based on user preference ────────────────
389
+ async def try_gemini() -> tuple[str, str]: return await _call_gemini_with_fallback(user_message)
390
+ async def try_openrouter() -> tuple[str, str]: return await _call_openrouter_safe(user_message)
391
+ async def try_groq() -> tuple[str, str]: return await _call_groq_safe(user_message)
392
+
393
+ preference = request.model_preference.lower()
394
+ if preference == "openrouter":
395
+ provider_order = [try_openrouter, try_gemini, try_groq]
396
+ elif preference == "groq":
397
+ provider_order = [try_groq, try_gemini, try_openrouter]
398
+ else: # default: gemini first
399
+ provider_order = [try_gemini, try_openrouter, try_groq]
400
+
401
+ # ── Try each provider, moving on only if rate-limited or unavailable ──────
402
+ raw_text: str = ""
403
+ provider_used: str = "unknown"
404
+ last_error: Exception | None = None
405
+
406
+ for provider_fn in provider_order:
407
+ try:
408
+ raw_text, provider_used = await provider_fn()
409
+ break # success β€” stop trying fallbacks
410
+ except RuntimeError as exc:
411
+ # RuntimeError = "no key configured" or "all keys exhausted"
412
+ logger.warning("Provider unavailable: %s", exc)
413
+ last_error = exc
414
+ continue
415
+ except Exception as exc:
416
+ if _is_rate_limit_error(exc):
417
+ logger.warning("Rate limit on provider, falling back: %s", exc)
418
+ last_error = exc
419
+ continue
420
+ # Unexpected non-rate-limit error β€” still try next provider
421
+ logger.error("Unexpected provider error, falling back: %s", exc)
422
+ last_error = exc
423
+ continue
424
+ else:
425
+ # All providers failed
426
+ raise HTTPException(
427
+ status_code=503,
428
+ detail=f"All AI providers failed. Last error: {last_error}",
429
+ )
430
+
431
+ # ── Parse response ────────────────────────────────────────────────────────
432
+ try:
433
+ files_data = _parse_files_from_response(raw_text)
434
+ except ValueError as exc:
435
+ raise HTTPException(
436
+ status_code=500,
437
+ detail=f"AI returned unparseable output: {exc}",
438
+ )
439
+
440
+ # ── Validate & build response objects ─────────────────────────────────────
441
+ generated_files: list[GeneratedFile] = []
442
+ for item in files_data:
443
+ if not isinstance(item, dict):
444
+ logger.warning("Skipping non-dict item in AI response: %s", item)
445
+ continue
446
+ fn = item.get("filename", "").strip()
447
+ lang = item.get("language", "").strip().lower()
448
+ code = item.get("code", "")
449
+ if not fn or code is None:
450
+ logger.warning("Skipping incomplete file entry: %s", item)
451
+ continue
452
+ generated_files.append(GeneratedFile(filename=fn, language=lang, code=code))
453
+
454
+ if not generated_files:
455
+ raise HTTPException(
456
+ status_code=500,
457
+ detail="AI returned an empty list of files. No changes were made.",
458
+ )
459
+
460
+ return AgentResponse(
461
+ files=generated_files,
462
+ provider_used=provider_used,
463
+ total_files_changed=len(generated_files),
464
+ )
465
+
466
+
467
+ # ══════════════════════════════════════════════════════════════════════════════
468
+ # FASTAPI ROUTE β€” add this to your main.py router
469
+ # ══════════════════════════════════════════════════════════════════════════════
470
+ #
471
+ # from fastapi import APIRouter
472
+ # router = APIRouter()
473
+ #
474
+ # @router.post("/api/agent/generate", response_model=AgentResponse)
475
+ # async def agent_generate(request: AgentRequest):
476
+ # """
477
+ # Agentic code generation / editing endpoint for Ethrix-Forge.
478
+ # Accepts the user prompt + existing project files as context,
479
+ # returns only the files that were created or modified.
480
+ # """
481
+ # return await run_agentic_workflow(request)
482
+ #
483
+ # ══════════════════════════════════════════════════════════════════════════════
main.py CHANGED
@@ -1,202 +1,1093 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
 
2
  import json
 
3
  import asyncio
 
 
4
  import logging
5
- from typing import Any, List
6
- from fastapi import FastAPI, HTTPException, Request
7
- from fastapi.middleware.cors import CORSMiddleware
8
- from pydantic import BaseModel
9
  import httpx
10
- from google import genai
 
 
 
 
 
 
 
 
 
 
11
 
12
- # ── LOGGING SETUP ──
13
- logging.basicConfig(level=logging.INFO)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  log = logging.getLogger("ethrix-forge")
15
 
16
- app = FastAPI(title="Ethrix-Forge AI Gateway")
 
 
 
 
 
 
 
 
 
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  app.add_middleware(
19
  CORSMiddleware,
20
- allow_origins=["*"],
21
  allow_credentials=True,
22
  allow_methods=["*"],
23
  allow_headers=["*"],
24
  )
25
 
26
- # ═══════════════════��══════════════════════════════════════════════════════════
27
- # CLAUDE'S AGENTIC WORKFLOW CODE (Directly inside main.py!)
28
- # ══════════════════════════════════════════════════════════════════════════════
29
-
30
- class ExistingFile(BaseModel):
31
- filename: str
32
- language: str
33
- code: str
34
-
35
- class AgentRequest(BaseModel):
36
- prompt: str
37
- existing_files: List[ExistingFile] = []
38
- model_preference: str = "gemini"
39
-
40
- class GeneratedFile(BaseModel):
41
- filename: str
42
- language: str
43
- code: str
44
-
45
- class AgentResponse(BaseModel):
46
- files: List[GeneratedFile]
47
- provider_used: str
48
- total_files_changed: int
49
-
50
- AGENTIC_SYSTEM_PROMPT = """
51
- You are Ethrix, an elite Expert Senior Software Architect.
52
- Your goal is to fulfill the user's request by analyzing the existing codebase and returning ONLY the files that need to be created or modified.
53
-
54
- STRICT RULES:
55
- 1. You MUST respond with ONLY a valid JSON array of objects.
56
- 2. NO markdown formatting, NO backticks (```json), NO explanations, NO preambles.
57
- 3. Each object in the array MUST have exactly three string keys: "filename", "language", and "code".
58
- 4. If a file does not need changes, DO NOT include it in the output.
59
- 5. Provide complete code for the files you do output (no "..." or "insert here" placeholders).
60
-
61
- Expected Output Format exactly like this:
62
- [
63
- {
64
- "filename": "index.html",
65
- "language": "html",
66
- "code": "<!DOCTYPE html>..."
67
- }
68
- ]
69
- """
70
 
71
- def _get_gemini_keys():
72
- keys = []
73
- base_key = os.getenv("GEMINI_API_KEY")
74
- if base_key: keys.append(base_key)
75
- for i in range(2, 10):
76
- k = os.getenv(f"GEMINI_API_KEY_{i}")
77
- if k: keys.append(k)
78
- return keys
79
-
80
- async def _call_gemini_with_fallback(full_prompt: str) -> tuple[str, str]:
81
- keys = _get_gemini_keys()
82
- if not keys:
83
- raise ValueError("No Gemini keys found")
84
-
85
- models_to_try = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-1.5-flash"]
86
-
87
- for key in keys:
88
- client = genai.Client(api_key=key)
89
- for model in models_to_try:
90
- try:
91
- def sync_call():
92
- return client.models.generate_content(
93
- model=model,
94
- contents=full_prompt
95
- )
96
- response = await asyncio.to_thread(sync_call)
97
- return response.text, f"gemini ({model})"
98
- except Exception as e:
99
- err_str = str(e)
100
- log.warning(f"Gemini {model} failed with key ending in ...{key[-4:]}: {err_str}")
101
- if "429" in err_str or "RESOURCE_EXHAUSTED" in err_str:
102
- break # Switch to next API Key
103
- raise ValueError("All Gemini keys and models exhausted.")
104
-
105
- async def _call_openrouter_safe(full_prompt: str) -> tuple[str, str]:
106
- api_key = os.getenv("OPENROUTER_API_KEY")
107
- if not api_key: raise ValueError("OpenRouter key missing")
108
-
109
- async with httpx.AsyncClient(timeout=45.0) as client:
110
- res = await client.post(
111
- "[https://openrouter.ai/api/v1/chat/completions](https://openrouter.ai/api/v1/chat/completions)",
112
- headers={"Authorization": f"Bearer {api_key}"},
113
- json={
114
- "model": "qwen/qwen-2.5-coder-32b-instruct:free",
115
- "messages": [{"role": "user", "content": full_prompt}]
116
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  )
118
- res.raise_for_status()
119
- return res.json()["choices"][0]["message"]["content"], "openrouter (qwen-2.5)"
120
-
121
- async def _call_groq_safe(full_prompt: str) -> tuple[str, str]:
122
- api_key = os.getenv("GROQ_API_KEY")
123
- if not api_key: raise ValueError("Groq key missing")
124
-
125
- async with httpx.AsyncClient(timeout=30.0) as client:
126
- res = await client.post(
127
- "[https://api.groq.com/openai/v1/chat/completions](https://api.groq.com/openai/v1/chat/completions)",
128
- headers={"Authorization": f"Bearer {api_key}"},
129
- json={
130
- "model": "llama-3.3-70b-versatile",
131
- "messages": [{"role": "user", "content": full_prompt}]
132
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  )
134
- res.raise_for_status()
135
- return res.json()["choices"][0]["message"]["content"], "groq (llama-3.3)"
136
-
137
- def _parse_files_from_response(raw_text: str) -> list[dict]:
138
- text = raw_text.strip()
139
- if text.startswith("```json"): text = text[7:]
140
- if text.startswith("```"): text = text[3:]
141
- if text.endswith("```"): text = text[:-3]
142
- text = text.strip()
143
-
 
 
 
 
 
 
 
144
  try:
145
- start = text.find('[')
146
- end = text.rfind(']')
147
- if start != -1 and end != -1:
148
- return json.loads(text[start:end+1])
149
- return json.loads(text)
150
- except json.JSONDecodeError as e:
151
- log.error(f"Failed to parse JSON: {e}\nRaw Output: {text[:200]}...")
152
- raise ValueError("AI did not return valid JSON.")
153
-
154
- async def run_agentic_workflow(request: AgentRequest) -> AgentResponse:
155
- # 1. Build Context
156
- context_str = "\n".join(
157
- f"File: {f.filename}\n```{f.language}\n{f.code}\n```"
158
- for f in request.existing_files
159
- )
160
- full_prompt = f"{AGENTIC_SYSTEM_PROMPT}\n\n--- EXISTING FILES ---\n{context_str}\n\n--- TASK ---\n{request.prompt}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
- # 2. Try Providers
163
- raw_response, provider = "", ""
164
  try:
165
- raw_response, provider = await _call_gemini_with_fallback(full_prompt)
166
- except Exception as e1:
167
- log.warning(f"Gemini chain failed: {e1}. Trying OpenRouter...")
 
 
 
 
 
 
 
 
 
168
  try:
169
- raw_response, provider = await _call_openrouter_safe(full_prompt)
170
- except Exception as e2:
171
- log.warning(f"OpenRouter failed: {e2}. Trying Groq...")
 
172
  try:
173
- raw_response, provider = await _call_groq_safe(full_prompt)
174
- except Exception as e3:
175
- raise HTTPException(status_code=502, detail="All AI providers exhausted.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
- # 3. Parse JSON
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  try:
179
- files_data = _parse_files_from_response(raw_response)
180
- generated_files = [
181
- GeneratedFile(filename=f["filename"], language=f["language"], code=f["code"])
182
- for f in files_data if "filename" in f and "code" in f
183
- ]
184
- return AgentResponse(
185
- files=generated_files,
186
- provider_used=provider,
187
- total_files_changed=len(generated_files)
188
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  except Exception as e:
190
  raise HTTPException(status_code=500, detail=str(e))
191
 
192
- # ══════════════════════════════════════════════════════════════════════════════
193
- # FASTAPI ENDPOINTS
194
- # ══════════════════════════════════════════════════════════════════════════════
195
 
196
- @app.get("/")
197
- def read_root():
198
- return {"status": "Ethrix-Forge API is Running (Agentic Mode)"}
 
 
 
 
 
 
 
 
 
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  @app.post("/api/agent/generate", response_model=AgentResponse)
201
  async def agent_generate(request: AgentRequest):
202
- return await run_agentic_workflow(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ╔══════════════════════════════════════════════════════════════════════════════╗
3
+ β•‘ ETHRIX-FORGE β€” FASTAPI CLOUD SYNC & BACKEND ENGINE β•‘
4
+ β•‘ main.py | Deployed on Hugging Face Spaces (Docker) β•‘
5
+ β•‘ β•‘
6
+ β•‘ Modules: β•‘
7
+ β•‘ 1. MongoDB β€” Workspace & Chat History persistence (motor) β•‘
8
+ β•‘ 2. GitHub β€” Clone / Commit / Push via PyGithub + GitPython β•‘
9
+ β•‘ 3. Google Drive β€” OAuth2 ZIP upload β•‘
10
+ β•‘ 4. AI Gateway β€” Secure proxy for Gemini & Groq (keys from HF env) β•‘
11
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
12
+
13
+ HUGGING FACE SPACE ENVIRONMENT VARIABLES (set in Space Settings β†’ Variables):
14
+ MONGODB_URI β€” MongoDB Atlas connection string
15
+ GEMINI_API_KEY β€” Google AI Studio key
16
+ GROQ_API_KEY β€” Groq Cloud key
17
+ GITHUB_TOKEN β€” GitHub Personal Access Token (optional default)
18
+ GOOGLE_CLIENT_ID β€” Google OAuth2 Client ID
19
+ GOOGLE_CLIENT_SECRET β€” Google OAuth2 Client Secret
20
+ GOOGLE_REDIRECT_URI β€” OAuth2 callback URL (e.g. https://your-space.hf.space/drive/callback)
21
+ SECRET_KEY β€” Random secret for session signing (generate with: openssl rand -hex 32)
22
+ """
23
+
24
  import os
25
+ import io
26
+ import re
27
  import json
28
+ import uuid
29
  import asyncio
30
+ import zipfile
31
+ import tempfile
32
  import logging
33
+ import shutil
34
+ from datetime import datetime, timezone
35
+ from typing import Optional, List, Any, Dict
36
+
37
  import httpx
38
+ from fastapi import FastAPI, HTTPException, Request, BackgroundTasks, Depends, Header
39
+ from fastapi.middleware.cors import CORSMiddleware
40
+ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
41
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
42
+ from contextlib import asynccontextmanager
43
+ from pydantic import BaseModel, Field
44
+
45
+ # ── Database ──────────────────────────────────────────────────────────────────
46
+ import motor.motor_asyncio
47
+ from bson import ObjectId
48
+ from bson.errors import InvalidId
49
 
50
+ # ── GitHub ────────────────────────────────────────────────────────────────────
51
+ from github import Github, GithubException
52
+ import git # GitPython
53
+
54
+ # ── Google OAuth / Drive ──────────────────────────────────────────────────────
55
+ from google_auth_oauthlib.flow import Flow
56
+ from google.oauth2.credentials import Credentials
57
+ from googleapiclient.discovery import build
58
+ from googleapiclient.http import MediaIoBaseUpload
59
+ from google.auth.transport.requests import Request as GoogleRequest
60
+ from google import genai
61
+ import asyncio
62
+ import logging
63
+ from ai_gateway import AgentRequest, AgentResponse, run_agentic_workflow
64
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
65
+ # ─────────────────────────────────────────────────────────────────────────────
66
+ # LOGGING
67
+ # ─────────────────────────────────────────────────────────────────────────────
68
+ logging.basicConfig(
69
+ level=logging.INFO,
70
+ format="%(asctime)s %(levelname)-8s %(name)s β€” %(message)s",
71
+ datefmt="%Y-%m-%d %H:%M:%S",
72
+ )
73
  log = logging.getLogger("ethrix-forge")
74
 
75
+ # ─────────────────────────────────────────────────────────────────────────────
76
+ # ENVIRONMENT VARIABLES
77
+ # ─────────────────────────────────────────────────────���───────────────────────
78
+ MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
79
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
80
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
81
+ GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") # optional server-side default
82
+ GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
83
+ GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
84
+ GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/drive/callback")
85
+ SECRET_KEY = os.getenv("SECRET_KEY", uuid.uuid4().hex) # fallback for dev only
86
 
87
+ # Hugging Face Spaces runs on port 7860 by default
88
+ PORT = int(os.getenv("PORT", 7860))
89
+
90
+ # ─────────────────────────────────────────────────────────────────────────────
91
+ # MONGODB CLIENT (module-level; initialised in lifespan)
92
+ # ─────────────────────────────────────────────────────────────────────────────
93
+ _mongo_client: Optional[motor.motor_asyncio.AsyncIOMotorClient] = None
94
+
95
+
96
+ def get_db() -> motor.motor_asyncio.AsyncIOMotorDatabase:
97
+ """Dependency: returns the 'ethrix_forge' database handle."""
98
+ if _mongo_client is None:
99
+ raise HTTPException(status_code=503, detail="Database not initialised yet.")
100
+ return _mongo_client["ethrix_forge"]
101
+
102
+
103
+ # ─────────────────────────────────────────────────────────────────────────────
104
+ # IN-MEMORY GOOGLE OAUTH SESSION STORE
105
+ # For production, replace with Redis or a DB-backed store.
106
+ # ─────────────────────────────────────────────────────────────────────────────
107
+ _drive_sessions: Dict[str, dict] = {} # state_token β†’ credentials dict
108
+
109
+
110
+ # ─────────────────────────────────────────────────────────────────────────────
111
+ # LIFESPAN β€” startup / shutdown
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+ @asynccontextmanager
114
+ async def lifespan(app: FastAPI):
115
+ global _mongo_client
116
+ log.info("πŸ”Œ Connecting to MongoDB…")
117
+ try:
118
+ _mongo_client = motor.motor_asyncio.AsyncIOMotorClient(
119
+ MONGODB_URI,
120
+ serverSelectionTimeoutMS=5_000,
121
+ )
122
+ # Ping to verify connection
123
+ await _mongo_client.admin.command("ping")
124
+ log.info("βœ… MongoDB connected.")
125
+ # Create indexes
126
+ db = _mongo_client["ethrix_forge"]
127
+ await db.workspaces.create_index("user_id")
128
+ await db.workspaces.create_index("updated_at")
129
+ await db.chat_history.create_index("workspace_id")
130
+ await db.chat_history.create_index("user_id")
131
+ log.info("βœ… MongoDB indexes ensured.")
132
+ except Exception as exc:
133
+ log.error(f"❌ MongoDB connection failed: {exc}")
134
+ # Don't crash β€” endpoints will return 503 gracefully
135
+
136
+ yield # ← app runs here
137
+
138
+ if _mongo_client:
139
+ _mongo_client.close()
140
+ log.info("πŸ”Œ MongoDB disconnected.")
141
+
142
+
143
+ # ─────────────────────────────────────────────────────────────────────────────
144
+ # FASTAPI APP
145
+ # ─────────────────────────────────────────────────────────────────────────────
146
+ app = FastAPI(
147
+ title="Ethrix-Forge Backend",
148
+ description="Cloud Sync, GitHub, Google Drive & AI Gateway for Ethrix-Forge IDE",
149
+ version="1.0.0",
150
+ lifespan=lifespan,
151
+ )
152
+
153
+ # ── CORS ──────────────────────────────────────────────────────────────────────
154
+ # In production, restrict allow_origins to your HF Space / Vercel URL.
155
  app.add_middleware(
156
  CORSMiddleware,
157
+ allow_origins=["*"], # ← tighten this in production
158
  allow_credentials=True,
159
  allow_methods=["*"],
160
  allow_headers=["*"],
161
  )
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
+ # ─────────────────────────────────────────────────────────────────────────────
165
+ # HELPERS
166
+ # ─────────────────────────────────────────────────────────────────────────────
167
+
168
+ def _serialize_doc(doc: dict) -> dict:
169
+ """Convert MongoDB ObjectId β†’ str for JSON serialisation."""
170
+ if doc is None:
171
+ return {}
172
+ out = {}
173
+ for k, v in doc.items():
174
+ if isinstance(v, ObjectId):
175
+ out[k] = str(v)
176
+ elif isinstance(v, datetime):
177
+ out[k] = v.isoformat()
178
+ elif isinstance(v, dict):
179
+ out[k] = _serialize_doc(v)
180
+ elif isinstance(v, list):
181
+ out[k] = [_serialize_doc(i) if isinstance(i, dict) else i for i in v]
182
+ else:
183
+ out[k] = v
184
+ return out
185
+
186
+
187
+ def _now() -> datetime:
188
+ return datetime.now(timezone.utc)
189
+
190
+
191
+ def _validate_object_id(oid: str) -> ObjectId:
192
+ try:
193
+ return ObjectId(oid)
194
+ except (InvalidId, Exception):
195
+ raise HTTPException(status_code=400, detail=f"Invalid document ID: '{oid}'")
196
+
197
+
198
+ # ════════════════════════════���════════════════════════════════════════════════
199
+ # β–ˆβ–ˆβ–ˆ MODULE 1 β€” MONGODB β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
200
+ # ═════════════════════════════════════════════════════════════════════════════
201
+
202
+ # ── Pydantic Models ──────────────────────────────────────────────────────────
203
+
204
+ class FileObject(BaseModel):
205
+ """A single code file in the workspace."""
206
+ filename: str = Field(..., example="index.html")
207
+ language: str = Field(..., example="html")
208
+ code: str = Field(..., example="<!DOCTYPE html>...")
209
+
210
+
211
+ class WorkspaceSaveRequest(BaseModel):
212
+ """Payload to save/update a workspace."""
213
+ user_id: str = Field(..., example="user_abc123")
214
+ name: str = Field(..., example="My Landing Page")
215
+ files: List[FileObject]
216
+ metadata: Optional[dict] = Field(default_factory=dict)
217
+
218
+
219
+ class WorkspaceResponse(BaseModel):
220
+ workspace_id: str
221
+ user_id: str
222
+ name: str
223
+ files: List[dict]
224
+ metadata: dict
225
+ created_at: str
226
+ updated_at: str
227
+
228
+
229
+ class ChatMessage(BaseModel):
230
+ """A single chat message in the AI conversation history."""
231
+ role: str = Field(..., example="user") # "user" | "assistant" | "system"
232
+ content: str
233
+ timestamp: Optional[str] = None
234
+
235
+
236
+ class ChatHistorySaveRequest(BaseModel):
237
+ workspace_id: str
238
+ user_id: str
239
+ messages: List[ChatMessage]
240
+
241
+
242
+ # ── Endpoints ────────────────────────────────────────────────────────────────
243
+
244
+ @app.post("/workspace/save", tags=["MongoDB"])
245
+ async def save_workspace(
246
+ payload: WorkspaceSaveRequest,
247
+ db=Depends(get_db),
248
+ ):
249
+ """
250
+ Create or update a workspace (files + metadata).
251
+ If a workspace already exists for this user_id + name, it is overwritten.
252
+ Returns the workspace_id.
253
+ """
254
+ now = _now()
255
+ doc = {
256
+ "user_id": payload.user_id,
257
+ "name": payload.name,
258
+ "files": [f.model_dump() for f in payload.files],
259
+ "metadata": payload.metadata or {},
260
+ "updated_at": now,
261
+ }
262
+
263
+ # Upsert by user_id + name so the user doesn't accumulate duplicate workspaces
264
+ result = await db.workspaces.find_one_and_update(
265
+ {"user_id": payload.user_id, "name": payload.name},
266
+ {
267
+ "$set": doc,
268
+ "$setOnInsert": {"created_at": now},
269
+ },
270
+ upsert=True,
271
+ return_document=True,
272
+ )
273
+
274
+ if result is None:
275
+ # Upsert inserted a new doc β€” fetch it
276
+ result = await db.workspaces.find_one(
277
+ {"user_id": payload.user_id, "name": payload.name}
278
  )
279
+
280
+ workspace_id = str(result["_id"])
281
+ log.info(f"Workspace saved: {workspace_id} for user {payload.user_id}")
282
+ return {"workspace_id": workspace_id, "message": "Workspace saved successfully."}
283
+
284
+
285
+ @app.get("/workspace/{workspace_id}", tags=["MongoDB"])
286
+ async def load_workspace(workspace_id: str, db=Depends(get_db)):
287
+ """Load a full workspace by its MongoDB ObjectId."""
288
+ oid = _validate_object_id(workspace_id)
289
+ doc = await db.workspaces.find_one({"_id": oid})
290
+ if not doc:
291
+ raise HTTPException(status_code=404, detail=f"Workspace '{workspace_id}' not found.")
292
+ return _serialize_doc(doc)
293
+
294
+
295
+ @app.get("/workspace/user/{user_id}", tags=["MongoDB"])
296
+ async def list_workspaces(user_id: str, db=Depends(get_db)):
297
+ """
298
+ List all workspaces belonging to a user (without file contents for efficiency).
299
+ """
300
+ cursor = db.workspaces.find(
301
+ {"user_id": user_id},
302
+ {"files": 0}, # Exclude file blobs from listing
303
+ ).sort("updated_at", -1)
304
+
305
+ docs = await cursor.to_list(length=100)
306
+ return {"workspaces": [_serialize_doc(d) for d in docs]}
307
+
308
+
309
+ @app.delete("/workspace/{workspace_id}", tags=["MongoDB"])
310
+ async def delete_workspace(workspace_id: str, db=Depends(get_db)):
311
+ """Permanently delete a workspace and its associated chat history."""
312
+ oid = _validate_object_id(workspace_id)
313
+ ws_result = await db.workspaces.delete_one({"_id": oid})
314
+ chat_result = await db.chat_history.delete_many({"workspace_id": workspace_id})
315
+
316
+ if ws_result.deleted_count == 0:
317
+ raise HTTPException(status_code=404, detail=f"Workspace '{workspace_id}' not found.")
318
+
319
+ return {
320
+ "message": "Workspace deleted.",
321
+ "files_deleted": ws_result.deleted_count,
322
+ "chat_messages_deleted": chat_result.deleted_count,
323
+ }
324
+
325
+
326
+ # ── Chat History ─────────────────────────────────────────────────────────────
327
+
328
+ @app.post("/chat/save", tags=["MongoDB"])
329
+ async def save_chat_history(payload: ChatHistorySaveRequest, db=Depends(get_db)):
330
+ """
331
+ Overwrite (replace) the chat history for a given workspace.
332
+ Each save replaces the full message array β€” the frontend manages the list.
333
+ """
334
+ now = _now()
335
+ messages_with_ts = []
336
+ for msg in payload.messages:
337
+ m = msg.model_dump()
338
+ if not m.get("timestamp"):
339
+ m["timestamp"] = now.isoformat()
340
+ messages_with_ts.append(m)
341
+
342
+ await db.chat_history.find_one_and_update(
343
+ {"workspace_id": payload.workspace_id, "user_id": payload.user_id},
344
+ {
345
+ "$set": {
346
+ "messages": messages_with_ts,
347
+ "updated_at": now,
348
+ },
349
+ "$setOnInsert": {"created_at": now},
350
+ },
351
+ upsert=True,
352
+ )
353
+ return {"message": "Chat history saved.", "message_count": len(messages_with_ts)}
354
+
355
+
356
+ @app.get("/chat/{workspace_id}", tags=["MongoDB"])
357
+ async def load_chat_history(workspace_id: str, db=Depends(get_db)):
358
+ """Load the full chat history for a workspace."""
359
+ doc = await db.chat_history.find_one({"workspace_id": workspace_id})
360
+ if not doc:
361
+ return {"messages": [], "workspace_id": workspace_id}
362
+ return _serialize_doc(doc)
363
+
364
+
365
+ @app.delete("/chat/{workspace_id}", tags=["MongoDB"])
366
+ async def clear_chat_history(workspace_id: str, db=Depends(get_db)):
367
+ """Delete all chat messages for a workspace."""
368
+ result = await db.chat_history.delete_many({"workspace_id": workspace_id})
369
+ return {"message": "Chat history cleared.", "deleted_count": result.deleted_count}
370
+
371
+
372
+ # ═════════════════════════════════════════════════════════════════════════════
373
+ # β–ˆβ–ˆβ–ˆ MODULE 2 β€” GITHUB β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
374
+ # ═════════════════════════════════════════════════════════════════════════════
375
+
376
+ class GitHubCloneRequest(BaseModel):
377
+ """Clone a GitHub repository into the workspace."""
378
+ repo_url: str = Field(..., example="https://github.com/owner/repo")
379
+ token: Optional[str] = Field(None, description="GitHub PAT. Falls back to server GITHUB_TOKEN.")
380
+ branch: Optional[str] = Field("main", description="Branch to clone")
381
+ workspace_id: Optional[str] = Field(None, description="Auto-save to this workspace after cloning")
382
+ user_id: Optional[str] = None
383
+
384
+
385
+ class GitHubCommitRequest(BaseModel):
386
+ """Commit and push files to a GitHub repository."""
387
+ repo_full_name: str = Field(..., example="owner/repo") # e.g. "torvalds/linux"
388
+ branch: str = Field("main")
389
+ commit_message: str = Field(..., example="feat: update from Ethrix-Forge")
390
+ files: List[FileObject]
391
+ token: Optional[str] = None
392
+
393
+
394
+ class GitHubRepoInfoRequest(BaseModel):
395
+ token: Optional[str] = None
396
+ repo_full_name: str
397
+
398
+
399
+ # ── Helper: resolve token ─────────────────────────────────────────────────────
400
+
401
+ def _resolve_github_token(request_token: Optional[str]) -> str:
402
+ token = request_token or GITHUB_TOKEN
403
+ if not token:
404
+ raise HTTPException(
405
+ status_code=400,
406
+ detail="No GitHub token provided and GITHUB_TOKEN env var is not set."
407
  )
408
+ return token
409
+
410
+
411
+ # ── Endpoints ────────────────────────────────────────────────────────────────
412
+
413
+ @app.post("/github/clone", tags=["GitHub"])
414
+ async def github_clone(payload: GitHubCloneRequest, db=Depends(get_db)):
415
+ """
416
+ Clone a GitHub repository into a temp directory, read all text files,
417
+ and return them as a FileObject array (optionally auto-saving to MongoDB).
418
+ """
419
+ token = _resolve_github_token(payload.token)
420
+
421
+ # Inject token into URL for private repo support
422
+ authenticated_url = _inject_token_into_git_url(payload.repo_url, token)
423
+
424
+ tmp_dir = tempfile.mkdtemp(prefix="ethrix_clone_")
425
  try:
426
+ log.info(f"Cloning {payload.repo_url} (branch: {payload.branch}) β†’ {tmp_dir}")
427
+ git.Repo.clone_from(
428
+ authenticated_url,
429
+ tmp_dir,
430
+ branch=payload.branch,
431
+ depth=1, # Shallow clone β€” faster, no full history needed
432
+ )
433
+ files = _read_directory_as_files(tmp_dir)
434
+ log.info(f"Cloned {len(files)} files from {payload.repo_url}")
435
+
436
+ except git.exc.GitCommandError as e:
437
+ raise HTTPException(
438
+ status_code=422,
439
+ detail=f"Git clone failed: {e.stderr.strip() if e.stderr else str(e)}"
440
+ )
441
+ finally:
442
+ shutil.rmtree(tmp_dir, ignore_errors=True)
443
+
444
+ # Optional: auto-save to MongoDB
445
+ workspace_id = None
446
+ if payload.workspace_id and payload.user_id:
447
+ save_payload = WorkspaceSaveRequest(
448
+ user_id=payload.user_id,
449
+ name=f"[GitHub] {payload.repo_url.split('/')[-1]}",
450
+ files=[FileObject(**f) for f in files],
451
+ )
452
+ result = await save_workspace(save_payload, db)
453
+ workspace_id = result["workspace_id"]
454
+
455
+ return {
456
+ "message": f"Cloned {len(files)} files successfully.",
457
+ "files": files,
458
+ "workspace_id": workspace_id,
459
+ }
460
+
461
+
462
+ @app.post("/github/commit-push", tags=["GitHub"])
463
+ async def github_commit_push(payload: GitHubCommitRequest):
464
+ """
465
+ Commit files to a GitHub repository using the GitHub Contents API.
466
+ Creates or updates each file, then creates a commit.
467
+ Works without needing a local git installation.
468
+ """
469
+ token = _resolve_github_token(payload.token)
470
 
 
 
471
  try:
472
+ gh = Github(token)
473
+ repo = gh.get_repo(payload.repo_full_name)
474
+ except GithubException as e:
475
+ raise HTTPException(
476
+ status_code=422,
477
+ detail=f"GitHub API error accessing '{payload.repo_full_name}': {e.data.get('message', str(e))}"
478
+ )
479
+
480
+ committed_files = []
481
+ errors = []
482
+
483
+ for file_obj in payload.files:
484
  try:
485
+ file_content = file_obj.code.encode("utf-8")
486
+ file_path = file_obj.filename
487
+
488
+ # Try to get existing file SHA (required for updates)
489
  try:
490
+ existing = repo.get_contents(file_path, ref=payload.branch)
491
+ repo.update_file(
492
+ path=file_path,
493
+ message=payload.commit_message,
494
+ content=file_content,
495
+ sha=existing.sha,
496
+ branch=payload.branch,
497
+ )
498
+ action = "updated"
499
+ except GithubException as ge:
500
+ if ge.status == 404:
501
+ repo.create_file(
502
+ path=file_path,
503
+ message=payload.commit_message,
504
+ content=file_content,
505
+ branch=payload.branch,
506
+ )
507
+ action = "created"
508
+ else:
509
+ raise
510
+
511
+ committed_files.append({"filename": file_path, "action": action})
512
+ log.info(f"GitHub: {action} '{file_path}' in {payload.repo_full_name}")
513
+
514
+ except GithubException as e:
515
+ errors.append({
516
+ "filename": file_obj.filename,
517
+ "error": e.data.get("message", str(e)),
518
+ })
519
+
520
+ return {
521
+ "message": f"Committed {len(committed_files)} files to {payload.repo_full_name}@{payload.branch}.",
522
+ "committed_files": committed_files,
523
+ "errors": errors,
524
+ "repo_url": f"https://github.com/{payload.repo_full_name}",
525
+ }
526
 
527
+
528
+ @app.post("/github/repo-info", tags=["GitHub"])
529
+ async def github_repo_info(payload: GitHubRepoInfoRequest):
530
+ """Fetch basic repository metadata (name, stars, branches, last commit)."""
531
+ token = _resolve_github_token(payload.token)
532
+ try:
533
+ gh = Github(token)
534
+ repo = gh.get_repo(payload.repo_full_name)
535
+ branches = [b.name for b in repo.get_branches()]
536
+ commit = repo.get_commits()[0]
537
+ return {
538
+ "name": repo.full_name,
539
+ "description": repo.description,
540
+ "private": repo.private,
541
+ "default_branch": repo.default_branch,
542
+ "branches": branches,
543
+ "stars": repo.stargazers_count,
544
+ "last_commit": {
545
+ "sha": commit.sha[:7],
546
+ "message": commit.commit.message.split("\n")[0],
547
+ "author": commit.commit.author.name,
548
+ "date": commit.commit.author.date.isoformat(),
549
+ },
550
+ "html_url": repo.html_url,
551
+ }
552
+ except GithubException as e:
553
+ raise HTTPException(status_code=422, detail=e.data.get("message", str(e)))
554
+
555
+
556
+ # ── Git helpers ───────────────────────────────────────────────────────────────
557
+
558
+ def _inject_token_into_git_url(url: str, token: str) -> str:
559
+ """Turn https://github.com/... β†’ https://TOKEN@github.com/..."""
560
+ return re.sub(r"https://", f"https://{token}@", url)
561
+
562
+
563
+ # Text-based extensions we'll read when importing a cloned repo
564
+ _TEXT_EXTENSIONS = {
565
+ ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".css", ".scss", ".sass",
566
+ ".json", ".md", ".txt", ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
567
+ ".h", ".sh", ".bash", ".yml", ".yaml", ".xml", ".sql", ".php", ".kt",
568
+ ".swift", ".dart", ".vue", ".svelte", ".toml", ".ini", ".env.example",
569
+ ".gitignore", ".dockerignore", "dockerfile", "makefile",
570
+ }
571
+
572
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".next"}
573
+
574
+
575
+ def _read_directory_as_files(root: str, max_files: int = 200) -> List[dict]:
576
+ """Walk a directory and return text files as FileObject-compatible dicts."""
577
+ results = []
578
+ for dirpath, dirnames, filenames in os.walk(root):
579
+ # Prune skipped directories in-place
580
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
581
+
582
+ for filename in filenames:
583
+ if len(results) >= max_files:
584
+ break
585
+ full_path = os.path.join(dirpath, filename)
586
+ rel_path = os.path.relpath(full_path, root).replace("\\", "/")
587
+ ext = os.path.splitext(filename)[1].lower()
588
+
589
+ if ext not in _TEXT_EXTENSIONS and filename.lower() not in _TEXT_EXTENSIONS:
590
+ continue
591
+
592
+ try:
593
+ with open(full_path, "r", encoding="utf-8", errors="ignore") as fh:
594
+ code = fh.read()
595
+ results.append({
596
+ "filename": rel_path,
597
+ "language": _ext_to_language(ext),
598
+ "code": code,
599
+ })
600
+ except OSError:
601
+ continue
602
+
603
+ return results
604
+
605
+
606
+ def _ext_to_language(ext: str) -> str:
607
+ mapping = {
608
+ ".js": "javascript", ".jsx": "javascript",
609
+ ".ts": "typescript", ".tsx": "typescript",
610
+ ".html": "html", ".htm": "html",
611
+ ".css": "css", ".scss": "scss", ".sass": "sass",
612
+ ".json": "json", ".md": "markdown",
613
+ ".py": "python", ".rb": "ruby", ".go": "go",
614
+ ".rs": "rust", ".java": "java", ".c": "c",
615
+ ".cpp": "cpp", ".sh": "shell", ".bash": "shell",
616
+ ".yml": "yaml", ".yaml": "yaml", ".xml": "xml",
617
+ ".sql": "sql", ".php": "php", ".kt": "kotlin",
618
+ ".swift": "swift", ".dart": "dart",
619
+ ".vue": "html", ".svelte": "html",
620
+ ".toml": "ini", ".ini": "ini",
621
+ }
622
+ return mapping.get(ext, "plaintext")
623
+
624
+
625
+ # ═════════════════════════════════════════════════════════════════════════════
626
+ # β–ˆβ–ˆβ–ˆ MODULE 3 β€” GOOGLE DRIVE β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
627
+ # ═════════════════════════════════════════════════════════════════════════════
628
+
629
+ # Drive scopes required
630
+ _DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.file"]
631
+
632
+
633
+ def _build_oauth_flow() -> Flow:
634
+ """Construct a google-auth-oauthlib Flow from environment credentials."""
635
+ if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
636
+ raise HTTPException(
637
+ status_code=503,
638
+ detail="Google OAuth2 is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET."
639
+ )
640
+ client_config = {
641
+ "web": {
642
+ "client_id": GOOGLE_CLIENT_ID,
643
+ "client_secret": GOOGLE_CLIENT_SECRET,
644
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
645
+ "token_uri": "https://oauth2.googleapis.com/token",
646
+ "redirect_uris": [GOOGLE_REDIRECT_URI],
647
+ }
648
+ }
649
+ flow = Flow.from_client_config(client_config, scopes=_DRIVE_SCOPES)
650
+ flow.redirect_uri = GOOGLE_REDIRECT_URI
651
+ return flow
652
+
653
+
654
+ # ── Endpoints ────────────────────────────────────────────────────────────────
655
+
656
+ @app.get("/drive/auth", tags=["Google Drive"])
657
+ async def drive_auth_start():
658
+ """
659
+ Step 1 of OAuth2: generate the Google authorisation URL.
660
+ The frontend redirects the user to the returned `auth_url`.
661
+ A `state` token is stored server-side to validate the callback.
662
+ """
663
+ flow = _build_oauth_flow()
664
+ auth_url, state = flow.authorization_url(
665
+ access_type="offline",
666
+ include_granted_scopes="true",
667
+ prompt="consent",
668
+ )
669
+ # Store state so the callback can verify it
670
+ _drive_sessions[state] = {"status": "pending"}
671
+ return {"auth_url": auth_url, "state": state}
672
+
673
+
674
+ @app.get("/drive/callback", tags=["Google Drive"])
675
+ async def drive_auth_callback(code: str, state: str):
676
+ """
677
+ Step 2 of OAuth2: Google redirects here with an auth code.
678
+ Exchange it for tokens and store them against the state key.
679
+ The frontend polls /drive/token/{state} to retrieve credentials.
680
+ """
681
+ if state not in _drive_sessions:
682
+ raise HTTPException(status_code=400, detail="Invalid or expired OAuth state token.")
683
+
684
+ flow = _build_oauth_flow()
685
+ try:
686
+ flow.fetch_token(code=code)
687
+ except Exception as e:
688
+ raise HTTPException(status_code=400, detail=f"Token exchange failed: {str(e)}")
689
+
690
+ creds = flow.credentials
691
+ _drive_sessions[state] = {
692
+ "status": "authenticated",
693
+ "token": creds.token,
694
+ "refresh_token": creds.refresh_token,
695
+ "token_uri": creds.token_uri,
696
+ "client_id": creds.client_id,
697
+ "client_secret": creds.client_secret,
698
+ "scopes": list(creds.scopes or _DRIVE_SCOPES),
699
+ }
700
+ log.info(f"Drive OAuth2 completed for state={state[:8]}…")
701
+ # Redirect to a frontend page that shows "Connected!" and closes the popup
702
+ return RedirectResponse(url="/?drive_connected=1")
703
+
704
+
705
+ @app.get("/drive/token/{state}", tags=["Google Drive"])
706
+ async def get_drive_token(state: str):
707
+ """
708
+ Frontend polls this endpoint after redirecting the user to /drive/auth.
709
+ Returns credential data when auth is complete.
710
+ """
711
+ session = _drive_sessions.get(state)
712
+ if not session:
713
+ raise HTTPException(status_code=404, detail="State token not found.")
714
+ return session
715
+
716
+
717
+ class DriveUploadRequest(BaseModel):
718
+ """Upload the workspace as a ZIP to Google Drive."""
719
+ files: List[FileObject]
720
+ zip_filename: str = Field("ethrix-forge-workspace.zip")
721
+ folder_id: Optional[str] = Field(None, description="Google Drive folder ID. Uploads to root if None.")
722
+ # Credentials returned by /drive/token/{state}
723
+ token: str
724
+ refresh_token: Optional[str] = None
725
+ token_uri: str = "https://oauth2.googleapis.com/token"
726
+ client_id: Optional[str] = None
727
+ client_secret: Optional[str] = None
728
+ scopes: Optional[List[str]] = None
729
+
730
+
731
+ @app.post("/drive/upload-workspace", tags=["Google Drive"])
732
+ async def drive_upload_workspace(payload: DriveUploadRequest):
733
+ """
734
+ Create a ZIP archive of all workspace files in memory and upload it
735
+ directly to the user's Google Drive. Returns the Drive file ID and URL.
736
+ """
737
+ # Build credentials
738
  try:
739
+ creds = Credentials(
740
+ token=payload.token,
741
+ refresh_token=payload.refresh_token,
742
+ token_uri=payload.token_uri,
743
+ client_id=payload.client_id or GOOGLE_CLIENT_ID,
744
+ client_secret=payload.client_secret or GOOGLE_CLIENT_SECRET,
745
+ scopes=payload.scopes or _DRIVE_SCOPES,
 
 
746
  )
747
+ # Refresh if expired
748
+ if creds.expired and creds.refresh_token:
749
+ creds.refresh(GoogleRequest())
750
+ except Exception as e:
751
+ raise HTTPException(status_code=401, detail=f"Invalid or expired Google credentials: {str(e)}")
752
+
753
+ # Build ZIP in memory
754
+ zip_buffer = io.BytesIO()
755
+ with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
756
+ for file_obj in payload.files:
757
+ zf.writestr(file_obj.filename, file_obj.code)
758
+ zip_buffer.seek(0)
759
+
760
+ # Upload to Drive
761
+ try:
762
+ service = build("drive", "v3", credentials=creds, cache_discovery=False)
763
+ metadata = {"name": payload.zip_filename}
764
+ if payload.folder_id:
765
+ metadata["parents"] = [payload.folder_id]
766
+
767
+ media = MediaIoBaseUpload(zip_buffer, mimetype="application/zip", resumable=True)
768
+ result = service.files().create(
769
+ body=metadata,
770
+ media_body=media,
771
+ fields="id, name, webViewLink, size",
772
+ ).execute()
773
+
774
+ log.info(f"Drive upload: {result.get('name')} (id={result.get('id')})")
775
+ return {
776
+ "message": "Workspace uploaded to Google Drive successfully.",
777
+ "file_id": result.get("id"),
778
+ "file_name": result.get("name"),
779
+ "web_view_link": result.get("webViewLink"),
780
+ "size_bytes": result.get("size"),
781
+ }
782
+ except Exception as e:
783
+ log.error(f"Drive upload failed: {e}")
784
+ raise HTTPException(status_code=500, detail=f"Google Drive upload failed: {str(e)}")
785
+
786
+
787
+ @app.get("/drive/files", tags=["Google Drive"])
788
+ async def drive_list_files(
789
+ token: str,
790
+ refresh_token: Optional[str] = None,
791
+ folder_id: Optional[str] = None,
792
+ ):
793
+ """List ZIP files in Google Drive (or a specific folder)."""
794
+ try:
795
+ creds = Credentials(
796
+ token=token,
797
+ refresh_token=refresh_token,
798
+ token_uri="https://oauth2.googleapis.com/token",
799
+ client_id=GOOGLE_CLIENT_ID,
800
+ client_secret=GOOGLE_CLIENT_SECRET,
801
+ scopes=_DRIVE_SCOPES,
802
+ )
803
+ if creds.expired and creds.refresh_token:
804
+ creds.refresh(GoogleRequest())
805
+
806
+ service = build("drive", "v3", credentials=creds, cache_discovery=False)
807
+ query = "mimeType='application/zip' and trashed=false"
808
+ if folder_id:
809
+ query += f" and '{folder_id}' in parents"
810
+
811
+ results = service.files().list(
812
+ q=query,
813
+ fields="files(id, name, size, createdTime, webViewLink)",
814
+ orderBy="createdTime desc",
815
+ pageSize=50,
816
+ ).execute()
817
+
818
+ return {"files": results.get("files", [])}
819
  except Exception as e:
820
  raise HTTPException(status_code=500, detail=str(e))
821
 
 
 
 
822
 
823
+ # ═════════════════════════════════════════════════════════════════════════════
824
+ # β–ˆβ–ˆβ–ˆ MODULE 4 β€” SECURE AI GATEWAY β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
825
+ # ═════════════════════════════════════════════════════════════════════════════
826
+ #
827
+ # The React frontend sends prompts here. The backend appends the real API keys
828
+ # (stored as HF Secrets) before forwarding to the AI providers.
829
+ # The frontend NEVER sees the raw API keys.
830
+ # ────────────────────────────────────────────────────────────────────────────���
831
+
832
+ # ── Strict JSON Auto-Coding System Prompt ────────────────────────────────────
833
+ # (Mirrors useAIService.js for consistency β€” the gateway can also be used
834
+ # as a backend-only generation path without the frontend SDK.)
835
 
836
+ SYSTEM_PROMPT = """You are Ethrix, an elite autonomous software engineer inside the Ethrix-Forge AI IDE. Your sole function is to generate complete, production-ready source code.
837
+
838
+ ABSOLUTE OUTPUT RULES β€” NEVER VIOLATE THESE:
839
+ 1. Your response MUST be a single, raw, valid JSON array. Nothing else.
840
+ 2. Each element: {"filename": "...", "language": "...", "code": "..."}
841
+ 3. NO markdown fences. NO backticks. NO explanation. NO preamble.
842
+ 4. All code must be complete and untruncated.
843
+ 5. Escape double-quotes inside "code" as \\", newlines as \\n.
844
+
845
+ You are a JSON-outputting machine. You do not converse."""
846
+
847
+ # ── Models ────────────────────────────────────────────────────────────────────
848
+
849
+ class AIGatewayRequest(BaseModel):
850
+ prompt: str = Field(..., description="User's coding request")
851
+ provider: str = Field("gemini", description="'gemini' or 'groq'")
852
+ model: Optional[str] = None
853
+
854
+
855
+ class AIGatewayResponse(BaseModel):
856
+ files: List[dict]
857
+ raw_response: str
858
+ provider: str
859
+ model: str
860
+
861
+
862
+ # ── Naya Gemini SDK Import ──
863
+ log = logging.getLogger("ethrix-forge")
864
+
865
+ # πŸš€ Shantanu's Ultimate Master Fallback Lists
866
+ GEMINI_MODELS = [
867
+ "gemini-2.5-flash", # πŸ”₯ Sabse fast aur powerful! (Top Priority)
868
+ "gemini-2.0-flash",
869
+ "gemini-2.0-flash-lite-preview-02-05",
870
+ "gemini-1.5-flash"
871
+ ]
872
+ OPENROUTER_MODELS = [
873
+ "qwen/qwen-2.5-coder-32b-instruct:free", # Free aur best coding
874
+ "meta-llama/llama-3.3-70b-instruct:free"
875
+ ]
876
+ GROQ_MODELS = [
877
+ "qwen-2.5-coder-32b",
878
+ "llama-3.3-70b-versatile"
879
+ ]
880
+
881
+ # Note: Maine naam _call_gemini_gateway hi rakha hai taaki tumhara baaki code na tute,
882
+ # par ab yeh ek MASTER GATEWAY ban chuka hai! 😎
883
+ async def _call_gemini_gateway(prompt: str, requested_model: str) -> str:
884
+ """The God Mode AI Gateway: Gemini -> OpenRouter -> Groq"""
885
+ full_prompt = f"{SYSTEM_PROMPT}\n\nUser Request:\n{prompt}"
886
+ last_error = ""
887
+
888
+ # 🟒 STEP 1: GEMINI (First Priority)
889
+ if GEMINI_API_KEY:
890
+ client = genai.Client(api_key=GEMINI_API_KEY)
891
+ for model in GEMINI_MODELS:
892
+ log.info(f"πŸ”„ Trying Gemini: {model}...")
893
+ try:
894
+ def sync_call(m):
895
+ return client.models.generate_content(model=m, contents=full_prompt)
896
+ response = await asyncio.to_thread(sync_call, model)
897
+ log.info(f"βœ… Success with Gemini {model}!")
898
+ return response.text
899
+ except Exception as e:
900
+ log.warning(f"⚠️ Gemini {model} failed: {str(e)}")
901
+ last_error = f"Gemini Error: {str(e)}"
902
+
903
+ # πŸ”΅ STEP 2: OPENROUTER (Second Priority)
904
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
905
+ if OPENROUTER_API_KEY:
906
+ async with httpx.AsyncClient(timeout=30.0) as client:
907
+ for model in OPENROUTER_MODELS:
908
+ log.info(f"πŸ”„ Trying OpenRouter: {model}...")
909
+ try:
910
+ res = await client.post(
911
+ "https://openrouter.ai/api/v1/chat/completions",
912
+ headers={"Authorization": f"Bearer {OPENROUTER_API_KEY}"},
913
+ json={"model": model, "messages": [{"role": "user", "content": full_prompt}]}
914
+ )
915
+ res.raise_for_status()
916
+ data = res.json()
917
+ log.info(f"βœ… Success with OpenRouter {model}!")
918
+ return data["choices"][0]["message"]["content"]
919
+ except Exception as e:
920
+ log.warning(f"⚠️ OpenRouter {model} failed: {str(e)}")
921
+ last_error = f"OpenRouter Error: {str(e)}"
922
+
923
+ # 🟠 STEP 3: GROQ (Third Priority - Flash Speed Backup)
924
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY") # Agar env variable ka naam GROQ_API_KEY hai
925
+ if GROQ_API_KEY:
926
+ async with httpx.AsyncClient(timeout=15.0) as client:
927
+ for model in GROQ_MODELS:
928
+ log.info(f"πŸ”„ Trying Groq: {model}...")
929
+ try:
930
+ res = await client.post(
931
+ "https://api.groq.com/openai/v1/chat/completions",
932
+ headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
933
+ json={"model": model, "messages": [{"role": "user", "content": full_prompt}]}
934
+ )
935
+ res.raise_for_status()
936
+ data = res.json()
937
+ log.info(f"βœ… Success with Groq {model}!")
938
+ return data["choices"][0]["message"]["content"]
939
+ except Exception as e:
940
+ log.warning(f"⚠️ Groq {model} failed: {str(e)}")
941
+ last_error = f"Groq Error: {str(e)}"
942
+
943
+ # ❌ FINAL ERROR: Agar duniya ke teeno bade AI down ho jayein (jo ki impossible hai)
944
+ log.error(f"❌ ALL AI PROVIDERS FAILED! Last error: {last_error}")
945
+ raise HTTPException(status_code=502, detail="All AI limits exhausted! Please check logs.")
946
+
947
+
948
+ async def _call_groq_gateway(prompt: str, model: str) -> str:
949
+ """Call Groq Chat Completions API."""
950
+ if not GROQ_API_KEY:
951
+ raise HTTPException(status_code=503, detail="GROQ_API_KEY is not set on the server.")
952
+
953
+ url = "https://api.groq.com/openai/v1/chat/completions"
954
+ body = {
955
+ "model": model,
956
+ "messages": [
957
+ {"role": "system", "content": SYSTEM_PROMPT},
958
+ {"role": "user", "content": prompt},
959
+ ],
960
+ "temperature": 0.2,
961
+ "max_tokens": 8192,
962
+ "response_format": {"type": "json_object"},
963
+ }
964
+ async with httpx.AsyncClient(timeout=120) as client:
965
+ resp = await client.post(
966
+ url, json=body,
967
+ headers={"Authorization": f"Bearer {GROQ_API_KEY}"}
968
+ )
969
+
970
+ if resp.status_code == 429:
971
+ raise HTTPException(status_code=429, detail="Groq rate limit exceeded. Please wait and retry.")
972
+ if resp.status_code in (401, 403):
973
+ raise HTTPException(status_code=502, detail="Groq API key is invalid or unauthorised.")
974
+ if not resp.is_success:
975
+ detail = resp.json().get("error", {}).get("message", resp.text)
976
+ raise HTTPException(status_code=502, detail=f"Groq error: {detail}")
977
+
978
+ data = resp.json()
979
+ return data.get("choices", [{}])[0].get("message", {}).get("content", "")
980
+
981
+
982
+ # ── JSON Parser (mirrors frontend logic) ─────────────────────────────────────
983
+
984
+ def _parse_gateway_response(raw: str, provider: str) -> List[dict]:
985
+ if not raw or not raw.strip():
986
+ raise HTTPException(status_code=502, detail=f"{provider} returned an empty response.")
987
+
988
+ text = raw.strip()
989
+
990
+ # Strategy 1: direct parse
991
+ try:
992
+ parsed = json.loads(text)
993
+ if isinstance(parsed, list):
994
+ return parsed
995
+ # Groq json_object mode may wrap the array in a key
996
+ if isinstance(parsed, dict):
997
+ for v in parsed.values():
998
+ if isinstance(v, list):
999
+ return v
1000
+ except json.JSONDecodeError:
1001
+ pass
1002
+
1003
+ # Strategy 2: strip markdown fences
1004
+ fence_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
1005
+ if fence_match:
1006
+ try:
1007
+ parsed = json.loads(fence_match.group(1).strip())
1008
+ if isinstance(parsed, list):
1009
+ return parsed
1010
+ except json.JSONDecodeError:
1011
+ pass
1012
+
1013
+ # Strategy 3: extract first [...] block
1014
+ array_match = re.search(r"\[[\s\S]*\]", text)
1015
+ if array_match:
1016
+ try:
1017
+ return json.loads(array_match.group(0))
1018
+ except json.JSONDecodeError as e:
1019
+ raise HTTPException(
1020
+ status_code=422,
1021
+ detail=f"AI returned malformed JSON from {provider}. Parse error: {str(e)}"
1022
+ )
1023
+
1024
+ raise HTTPException(
1025
+ status_code=422,
1026
+ detail=f"No valid JSON array found in {provider} response. Preview: {text[:300]}"
1027
+ )
1028
+
1029
+
1030
+ # ── Default models per provider ───────────────────────────────────────────────
1031
+ _GATEWAY_DEFAULT_MODELS = {
1032
+ "gemini": "gemini-2.0-flash",
1033
+ "groq": "llama-3.3-70b-versatile",
1034
+ }
1035
+
1036
+
1037
+ # ── Main Gateway Endpoint ─────────────────────────────────────────────────────
1038
+
1039
+ # Naya Agentic Endpoint
1040
  @app.post("/api/agent/generate", response_model=AgentResponse)
1041
  async def agent_generate(request: AgentRequest):
1042
+ return await run_agentic_workflow(request)
1043
+
1044
+ # ═════════════════════════════════════════════════════════════════════════════
1045
+ # HEALTH & ROOT
1046
+ # ═════════════════════════════════════════════════════════════════════════════
1047
+
1048
+ @app.get("/", tags=["Health"])
1049
+ async def root():
1050
+ return {
1051
+ "service": "Ethrix-Forge Backend",
1052
+ "version": "1.0.0",
1053
+ "status": "online",
1054
+ "docs": "/docs",
1055
+ "timestamp": _now().isoformat(),
1056
+ }
1057
+
1058
+
1059
+ @app.get("/health", tags=["Health"])
1060
+ async def health_check():
1061
+ """Deep health check β€” verifies MongoDB connectivity."""
1062
+ db_status = "disconnected"
1063
+ if _mongo_client:
1064
+ try:
1065
+ await _mongo_client.admin.command("ping")
1066
+ db_status = "connected"
1067
+ except Exception:
1068
+ db_status = "error"
1069
+
1070
+ return {
1071
+ "status": "ok" if db_status == "connected" else "degraded",
1072
+ "database": db_status,
1073
+ "ai_gateway": {
1074
+ "gemini_configured": bool(GEMINI_API_KEY),
1075
+ "groq_configured": bool(GROQ_API_KEY),
1076
+ },
1077
+ "github_configured": bool(GITHUB_TOKEN),
1078
+ "drive_configured": bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET),
1079
+ }
1080
+
1081
+
1082
+ # ═════════════════════════════════════════════════════════════════════════════
1083
+ # ENTRY POINT
1084
+ # ═════════════════════════════════════════════════════════════════════════════
1085
+ if __name__ == "__main__":
1086
+ import uvicorn
1087
+ uvicorn.run(
1088
+ "main:app",
1089
+ host="0.0.0.0",
1090
+ port=PORT,
1091
+ reload=False, # Disable in production
1092
+ log_level="info",
1093
+ )