SiddhJagani commited on
Commit
319d5b0
·
verified ·
1 Parent(s): 5c3c53e

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +476 -0
app.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import httpx
5
+ import uvicorn
6
+ import gradio as gr
7
+ from fastapi import FastAPI, Request, Header, HTTPException
8
+ from fastapi.responses import JSONResponse, StreamingResponse
9
+
10
+ # ---------------------------------------------------------------------
11
+ # Configuration
12
+ # ---------------------------------------------------------------------
13
+ BYTEZ_CHAT_URL = "https://api.bytez.com/models/v2/openai/v1/chat/completions"
14
+ BYTEZ_MODELS_URL = "https://api.bytez.com/models/v2/list/models"
15
+ BYTEZ_IMAGE_URL = "https://api.bytez.com/models/v2/openai/dall-e-3"
16
+ BYTEZ_AUTH = os.getenv("BYTEZ_API_KEY") # your Bytez key
17
+ LOCAL_API_KEY = os.getenv("LOCAL_API_KEY") # optional local guard
18
+ BYTEZ_AUTH_2 = os.getenv("BYTEZ_API_KEY_2")
19
+ # ---------------------------------------------------------------------
20
+ # FastAPI app
21
+ # ---------------------------------------------------------------------
22
+ api = FastAPI(title="Bytez → OpenAI Proxy (v1 + v2)")
23
+
24
+ def check_key(auth: str | None):
25
+ """Validate the Bearer token (optional local key)."""
26
+ if not auth or not auth.startswith("Bearer "):
27
+ raise HTTPException(status_code=401, detail="Missing or invalid API key")
28
+ user_key = auth.split("Bearer ")[1].strip()
29
+ if LOCAL_API_KEY and user_key != LOCAL_API_KEY:
30
+ raise HTTPException(status_code=403, detail="Unauthorized API key")
31
+
32
+ # ---------------------------------------------------------------------
33
+ # Root / health
34
+ # ---------------------------------------------------------------------
35
+ @api.get("/")
36
+ def root():
37
+ return {"status": "ok", "message": "Bytez proxy (v1+v2) running"}
38
+
39
+ # ---------------------------------------------------------------------
40
+ # -------------------------- /v1 ------------------------------------
41
+ # ---------------------------------------------------------------------
42
+
43
+ @api.get("/v1/models")
44
+ async def v1_models(authorization: str = Header(None)):
45
+ check_key(authorization)
46
+ if not BYTEZ_AUTH:
47
+ raise HTTPException(status_code=500, detail="Server BYTEZ_API_KEY not configured")
48
+
49
+ async with httpx.AsyncClient(timeout=30) as c:
50
+ r = await c.get(BYTEZ_MODELS_URL, headers={"Authorization": BYTEZ_AUTH})
51
+
52
+ try:
53
+ data = r.json()
54
+ except json.JSONDecodeError:
55
+ raise HTTPException(status_code=502, detail="Upstream returned invalid JSON")
56
+
57
+ # Transform Bytez → OpenAI list
58
+ models_list = [
59
+ {"id": m.get("id") or m.get("name"), "object": "model"}
60
+ for m in (data if isinstance(data, list) else data.get("data", []))
61
+ ]
62
+ return JSONResponse(
63
+ {"object": "list", "data": models_list},
64
+ headers={"Access-Control-Allow-Origin": "*"}
65
+ )
66
+
67
+
68
+ @api.post("/v1/chat/completions")
69
+ async def v1_chat(request: Request, authorization: str = Header(None)):
70
+ """Exactly the same implementation you already had – untouched."""
71
+ check_key(authorization)
72
+ if not BYTEZ_AUTH:
73
+ raise HTTPException(status_code=500, detail="Server BYTEZ_API_KEY not configured")
74
+
75
+ payload = await request.json()
76
+ stream = payload.get("stream", False)
77
+ headers = {"Authorization": BYTEZ_AUTH, "Content-Type": "application/json"}
78
+
79
+ # ---------- streaming helper ----------
80
+ async def v1_event_stream():
81
+ async with httpx.AsyncClient(timeout=120) as client:
82
+ async with client.stream("POST", BYTEZ_CHAT_URL, headers=headers, json=payload) as upstream:
83
+ async for line in upstream.aiter_lines():
84
+ line = line.strip()
85
+ if not line:
86
+ continue
87
+ json_str = line[6:] if line.startswith("data: ") else line
88
+ try:
89
+ chunk = json.loads(json_str)
90
+ except json.JSONDecodeError:
91
+ continue
92
+
93
+ if json_str == "[DONE]":
94
+ yield "data: [DONE]\n\n"
95
+ break
96
+
97
+ # ----- adapt Bytez chunk to OpenAI -----
98
+ content = ""
99
+ if "token" in chunk:
100
+ content = chunk["token"]
101
+ elif "choices" in chunk and chunk["choices"]:
102
+ delta = chunk["choices"][0].get("delta", {})
103
+ content = delta.get("content", "")
104
+ elif "text" in chunk:
105
+ content = chunk["text"]
106
+ else:
107
+ content = str(chunk)
108
+
109
+ openai_chunk = {
110
+ "id": "chatcmpl-proxy-stream",
111
+ "object": "chat.completion.chunk",
112
+ "created": int(time.time()),
113
+ "model": payload.get("model", "unknown"),
114
+ "choices": [
115
+ {
116
+ "index": 0,
117
+ "delta": {"role": "assistant", "content": content},
118
+ "finish_reason": None,
119
+ }
120
+ ],
121
+ }
122
+ yield f"data: {json.dumps(openai_chunk)}\n\n"
123
+ yield "data: [DONE]\n\n"
124
+
125
+ # ---------- non-stream ----------
126
+ if not stream:
127
+ async with httpx.AsyncClient(timeout=120) as c:
128
+ r = await c.post(BYTEZ_CHAT_URL, headers=headers, json=payload)
129
+ try:
130
+ data = r.json()
131
+ except json.JSONDecodeError:
132
+ raise HTTPException(status_code=502, detail="Upstream returned invalid JSON")
133
+
134
+ if "choices" not in data:
135
+ content = data.get("output") or data.get("response") or data.get("message") or str(data)
136
+ data = {
137
+ "id": "chatcmpl-proxy",
138
+ "object": "chat.completion",
139
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": content}}],
140
+ }
141
+ return JSONResponse(data, headers={"Access-Control-Allow-Origin": "*"})
142
+
143
+ return StreamingResponse(
144
+ v1_event_stream(),
145
+ media_type="text/event-stream",
146
+ headers={"Access-Control-Allow-Origin": "*"},
147
+ )
148
+
149
+ # ---------------------------------------------------------------------
150
+ # --------------------- /v1/images/generations (FIXED) ---------------
151
+ # ---------------------------------------------------------------------
152
+ # ---------------------------------------------------------------------
153
+ # --------------------- /v1/images/generations -----------------------
154
+ # ---------------------------------------------------------------------
155
+ @api.post("/v1/images/generations")
156
+ async def v1_images_generations(request: Request, authorization: str = Header(None)):
157
+ """
158
+ OpenAI-compatible image generation endpoint that forwards to:
159
+ https://api.bytez.com/models/v2/openai/dall-e-3
160
+
161
+ It accepts the usual OpenAI body:
162
+ {
163
+ "model": "dall-e-3", # REQUIRED by clients, but ignored
164
+ "prompt": "text", # REQUIRED
165
+ "n": 1,
166
+ "size": "1024x1024",
167
+ "response_format": "url" | "b64_json",
168
+ "quality": "standard" | "hd",
169
+ "style": "vivid" | "natural"
170
+ }
171
+ and converts it to the Bytez format from your curl:
172
+ { "text": "<prompt>" }
173
+ """
174
+ check_key(authorization)
175
+
176
+ # Use SAME key as chat unless you *really* have a separate one
177
+ if not BYTEZ_AUTH:
178
+ raise HTTPException(status_code=500, detail="Server BYTEZ_API_KEY not configured")
179
+
180
+ # ---------------- parse request ----------------
181
+ try:
182
+ payload = await request.json()
183
+ except json.JSONDecodeError:
184
+ raise HTTPException(status_code=400, detail="Invalid JSON")
185
+
186
+ prompt = payload.get("prompt") or payload.get("text")
187
+ if not prompt or not str(prompt).strip():
188
+ raise HTTPException(status_code=400, detail="Field 'prompt' is required")
189
+
190
+ # These are there only to satisfy OpenAI-compatible clients
191
+ _model = payload.get("model", "dall-e-3") # ignored
192
+ n = int(payload.get("n", 1))
193
+ response_format = payload.get("response_format", "url") # "url" | "b64_json"
194
+ size = payload.get("size", "1024x1024")
195
+ quality = payload.get("quality")
196
+ style = payload.get("style")
197
+
198
+ # ---------------- build Bytez request ----------------
199
+ # Minimal payload that we KNOW works from your curl:
200
+ bytez_payload = {
201
+ "text": prompt,
202
+ }
203
+
204
+ # Only add extras if Bytez actually supports them.
205
+ # Comment these out if you’re unsure; or keep them if Bytez docs say so.
206
+ # bytez_payload["num_outputs"] = n
207
+ # bytez_payload["size"] = size
208
+ # if quality in ["standard", "hd"]:
209
+ # bytez_payload["quality"] = quality
210
+ # if style in ["vivid", "natural"]:
211
+ # bytez_payload["style"] = style
212
+
213
+ headers = {
214
+ "Authorization": BYTEZ_AUTH, # << important
215
+ "Content-Type": "application/json",
216
+ }
217
+
218
+ # ---------------- call Bytez ----------------
219
+ async with httpx.AsyncClient(timeout=200) as client:
220
+ try:
221
+ resp = await client.post(
222
+ BYTEZ_IMAGE_URL,
223
+ json=bytez_payload,
224
+ headers=headers,
225
+ )
226
+ resp.raise_for_status()
227
+ except httpx.HTTPStatusError as e:
228
+ # Surface Bytez error message directly to client
229
+ detail = None
230
+ try:
231
+ detail = e.response.json()
232
+ except Exception:
233
+ detail = e.response.text
234
+ raise HTTPException(
235
+ status_code=e.response.status_code,
236
+ detail={"upstream_error": detail},
237
+ )
238
+ except Exception as e:
239
+ raise HTTPException(status_code=502, detail=f"Bytez unreachable: {str(e)}")
240
+
241
+ try:
242
+ bytez_data = resp.json()
243
+ except json.JSONDecodeError:
244
+ raise HTTPException(status_code=502, detail="Bytez returned invalid JSON")
245
+
246
+ # ---------------- map Bytez → OpenAI image response ----------------
247
+ # We don't know the exact shape, so we handle several possibilities.
248
+
249
+ images = []
250
+
251
+ # Case 1: { "images": ["url_or_b64", ...] }
252
+ if isinstance(bytez_data, dict) and "images" in bytez_data and isinstance(bytez_data["images"], list):
253
+ images = bytez_data["images"]
254
+
255
+ # Case 2: { "data": [ { "url": "..." } , ... ] }
256
+ elif isinstance(bytez_data, dict) and "data" in bytez_data and isinstance(bytez_data["data"], list):
257
+ for item in bytez_data["data"]:
258
+ if "url" in item:
259
+ images.append(item["url"])
260
+ elif "b64_json" in item:
261
+ images.append(item["b64_json"])
262
+ else:
263
+ images.append(str(item))
264
+
265
+ # Case 3: single string
266
+ elif isinstance(bytez_data, str):
267
+ images = [bytez_data]
268
+
269
+ # Fallback: treat whole thing as one string
270
+ else:
271
+ images = [str(bytez_data)]
272
+
273
+ if not images:
274
+ raise HTTPException(status_code=500, detail={"error": "No images returned from Bytez", "raw": bytez_data})
275
+
276
+ # truncate to n images
277
+ images = images[:n]
278
+
279
+ openai_data = []
280
+ for img in images:
281
+ # If it looks like a data URL already
282
+ if isinstance(img, str) and img.startswith("data:image"):
283
+ # Already a data URL; extract base64 if needed
284
+ b64_part = img.split("base64,", 1)[-1]
285
+ if response_format == "b64_json":
286
+ openai_data.append({"b64_json": b64_part})
287
+ else:
288
+ openai_data.append({"url": img})
289
+
290
+ else:
291
+ # Raw URL or base64 – we can't be sure, so:
292
+ if response_format == "b64_json":
293
+ # Assume it's base64 or treat as a string anyway
294
+ openai_data.append({"b64_json": str(img)})
295
+ else:
296
+ # Assume it's a URL or make it one if needed
297
+ openai_data.append({"url": str(img)})
298
+
299
+ result = {
300
+ "created": int(time.time()),
301
+ "data": openai_data,
302
+ }
303
+ return JSONResponse(result, headers={"Access-Control-Allow-Origin": "*"})
304
+
305
+
306
+
307
+ # ---------------------------------------------------------------------
308
+ # -------------------------- /v2 ------------------------------------
309
+ # ---------------------------------------------------------------------
310
+
311
+ @api.post("/v2/chat/completions")
312
+ async def v2_chat_completions(request: Request, authorization: str = Header(None)):
313
+ """
314
+ v2 – clean OpenAI-compatible streaming.
315
+ * First chunk includes role=assistant (required by Continue.dev)
316
+ * Later chunks send only delta.content
317
+ * No usage events
318
+ """
319
+
320
+ check_key(authorization)
321
+
322
+ if not BYTEZ_AUTH_2:
323
+ raise HTTPException(status_code=500, detail="Server BYTEZ_API_2 not configured")
324
+
325
+ try:
326
+ body = await request.body()
327
+ payload = json.loads(body.decode("utf-8"))
328
+ except json.JSONDecodeError as e:
329
+ raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
330
+
331
+ stream = payload.get("stream", False)
332
+
333
+ upstream_headers = {
334
+ "Authorization": BYTEZ_AUTH_2,
335
+ "Content-Type": "application/json",
336
+ }
337
+
338
+ # Normal content chunk (NO ROLE)
339
+ def make_openai_delta(content: str):
340
+ return {
341
+ "id": f"chatcmpl-v2-{int(time.time())}",
342
+ "object": "chat.completion.chunk",
343
+ "created": int(time.time()),
344
+ "model": payload.get("model", "unknown"),
345
+ "choices": [
346
+ {
347
+ "index": 0,
348
+ "delta": {"content": content},
349
+ "finish_reason": None,
350
+ }
351
+ ],
352
+ }
353
+
354
+ async def clean_stream():
355
+ # FIRST CHUNK MUST SET THE ROLE → REQUIRED by Continue.dev
356
+ first_chunk = {
357
+ "id": f"chatcmpl-v2-{int(time.time())}",
358
+ "object": "chat.completion.chunk",
359
+ "created": int(time.time()),
360
+ "model": payload.get("model", "unknown"),
361
+ "choices": [
362
+ {
363
+ "index": 0,
364
+ "delta": {"role": "assistant", "content": ""},
365
+ "finish_reason": None,
366
+ }
367
+ ],
368
+ }
369
+
370
+ # Send first role-setting chunk
371
+ yield f"data: {json.dumps(first_chunk)}\n\n"
372
+
373
+ async with httpx.AsyncClient(timeout=180) as client:
374
+ try:
375
+ async with client.stream(
376
+ "POST", BYTEZ_CHAT_URL, headers=upstream_headers, json=payload
377
+ ) as upstream:
378
+
379
+ async for line in upstream.aiter_lines():
380
+ line = line.strip()
381
+ if not line:
382
+ continue
383
+
384
+ json_str = line[6:] if line.startswith("data: ") else line
385
+
386
+ # Skip usage events
387
+ if "usage" in json_str.lower():
388
+ continue
389
+
390
+ if json_str == "[DONE]":
391
+ yield "data: [DONE]\n\n"
392
+ return
393
+
394
+ try:
395
+ chunk = json.loads(json_str)
396
+ except json.JSONDecodeError:
397
+ continue
398
+
399
+ text = ""
400
+ if isinstance(chunk, dict):
401
+ if "token" in chunk:
402
+ text = chunk["token"]
403
+ elif "choices" in chunk and chunk["choices"]:
404
+ delta = chunk["choices"][0].get("delta", {})
405
+ text = delta.get("content", "")
406
+ elif "text" in chunk:
407
+ text = chunk["text"]
408
+ else:
409
+ text = str(chunk)
410
+
411
+ if text:
412
+ yield f"data: {json.dumps(make_openai_delta(text))}\n\n"
413
+
414
+ yield "data: [DONE]\n\n"
415
+
416
+ except Exception as e:
417
+ error_chunk = make_openai_delta(f"Error: {str(e)}")
418
+ yield f"data: {json.dumps(error_chunk)}\n\n"
419
+ yield "data: [DONE]\n\n"
420
+
421
+ # Non-streaming mode
422
+ if not stream:
423
+ async with httpx.AsyncClient(timeout=120) as c:
424
+ r = await c.post(BYTEZ_CHAT_URL, headers=upstream_headers, json=payload)
425
+ r.raise_for_status()
426
+ data = r.json()
427
+
428
+ if "choices" not in data:
429
+ content = (
430
+ data.get("output")
431
+ or data.get("response")
432
+ or data.get("message")
433
+ or str(data)
434
+ )
435
+ data = {
436
+ "id": "chatcmpl-v2",
437
+ "object": "chat.completion",
438
+ "choices": [
439
+ {"index": 0, "message": {"role": "assistant", "content": content}}
440
+ ],
441
+ }
442
+
443
+ return JSONResponse(data, headers={"Access-Control-Allow-Origin": "*"})
444
+
445
+ # Streaming mode
446
+ return StreamingResponse(
447
+ clean_stream(),
448
+ media_type="text/event-stream",
449
+ headers={
450
+ "Access-Control-Allow-Origin": "*",
451
+ "Access-Control-Allow-Headers": "*",
452
+ "Cache-Control": "no-cache",
453
+ "Connection": "keep-alive",
454
+ "X-Accel-Buffering": "no",
455
+ },
456
+ )
457
+
458
+ # ---------------------------------------------------------------------
459
+ # Minimal Gradio UI (required for HF Space to start)
460
+ # ---------------------------------------------------------------------
461
+ with gr.Blocks() as ui:
462
+ gr.Markdown(
463
+ "### Bytez → OpenAI Proxy (v1 + **v2**)\n"
464
+ "- `/v1/models` \n"
465
+ "- `/v1/chat/completions` (unchanged) \n"
466
+ "- **`/v2/chat/completions`** – clean streaming, no usage chunk"
467
+ )
468
+
469
+ demo = gr.mount_gradio_app(api, ui, path="/")
470
+
471
+ # This makes it work on Render, Railway, Fly.io, etc.
472
+ app = api
473
+
474
+ if __name__ == "__main__":
475
+ # Only for local testing with Gradio
476
+ uvicorn.run(demo, host="0.0.0.0", port=8000)