bahi-bh commited on
Commit
aaf859a
·
verified ·
1 Parent(s): 9f4c0a5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +421 -915
app.py CHANGED
@@ -1,994 +1,500 @@
1
  import os
2
  import json
3
- import uuid
4
- import asyncio
5
  import logging
6
- from contextlib import asynccontextmanager
7
-
8
- import httpx
9
- from fastapi import FastAPI, Request, Header
10
- from fastapi.responses import JSONResponse, StreamingResponse
11
-
12
- # =========================================================
13
- # LOGGING
14
- # =========================================================
15
-
16
- logging.basicConfig(
17
- level=logging.INFO,
18
- format="%(asctime)s [%(levelname)s] %(message)s"
19
- )
20
- logger = logging.getLogger("gateway")
21
-
22
- # =========================================================
23
- # CONFIG
24
- # =========================================================
25
-
26
- OPENCODE_API_KEY = os.getenv("OPENCODE_API_KEY", "").strip()
27
- UPSTREAM_URL = "https://opencode.ai/zen/v1/chat/completions"
28
- MAX_RETRIES = 5
29
- RETRY_DELAY = 3
30
-
31
- UPSTREAM_HEADERS = {
32
- "Content-Type": "application/json",
33
- "Accept": "application/json",
34
- "Origin": "https://opencode.ai",
35
- "Referer": "https://opencode.ai/",
36
- "User-Agent": (
37
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
38
- "AppleWebKit/537.36 (KHTML, like Gecko) "
39
- "Chrome/124.0 Safari/537.36"
40
- ),
41
- }
42
-
43
- STREAM_HEADERS = {
44
- "Cache-Control": "no-cache",
45
- "Connection": "keep-alive",
46
- "X-Accel-Buffering": "no",
47
- }
48
-
49
- # =========================================================
50
- # MODEL MAP
51
- # =========================================================
52
-
53
- # Only models confirmed working from logs (no 401)
54
- _FREE_POOL = [
55
- "deepseek-v4-flash-free",
56
- "nemotron-3-super-free",
57
- ]
58
- _pool_idx = 0
59
-
60
-
61
- def _pick_free_model():
62
- """Round-robin across confirmed free models."""
63
- global _pool_idx
64
- model = _FREE_POOL[_pool_idx % len(_FREE_POOL)]
65
- _pool_idx += 1
66
- return model
67
-
68
-
69
- MODEL_MAP = {
70
- # Claude models -> round-robin free pool (resolved at request time)
71
- "claude-sonnet-4-6": None,
72
- "claude-sonnet-4-5": None,
73
- "claude-haiku-4-6": None,
74
- "claude-haiku-4-5": None,
75
- "claude-haiku-4-5-20251001": None,
76
- "claude-opus-4-6": "nemotron-3-super-free",
77
- # Direct aliases
78
- "deepseek-v4-flash-free": "deepseek-v4-flash-free",
79
- "nemotron-3-super-free": "nemotron-3-super-free",
80
- "qwen3.6-plus-free": "qwen3.6-plus-free",
81
- "minimax-m2.5-free": "minimax-m2.5-free",
82
- "big-pickle": "big-pickle",
83
- }
84
-
85
- DEFAULT_MODEL = "deepseek-v4-flash-free"
86
-
87
- _MODELS_CACHE = {
88
- "object": "list",
89
- "data": [
90
- {
91
- "id": m,
92
- "object": "model",
93
- "created": 1710000000,
94
- "owned_by": "gateway"
95
- }
96
- for m in MODEL_MAP
97
- ],
98
- }
99
-
100
- # =========================================================
101
- # BROWSER TOOLS — strip before sending to DeepSeek
102
- # =========================================================
103
-
104
- _BROWSER_TOOLS = {
105
- "list_connected_browsers", "tabs_context_mcp",
106
- "browser_action", "screenshot", "puppeteer",
107
- "open_browser", "web_browser", "navigate",
108
- "click", "type_text", "get_page_content",
109
- "scroll", "wait_for_element",
110
- }
111
 
112
- # =========================================================
113
- # THINK-TAG PARSER
114
- # Parses <think>…</think> tags from streaming text.
115
- # Some models wrap reasoning in tags instead of a field.
116
- # Ported from github.com/Alishahryar1/free-claude-code
117
- # =========================================================
118
-
119
- class _ThinkParser:
120
-
121
- OPEN = "<think>"
122
- CLOSE = "</think>"
123
-
124
- def __init__(self):
125
- self._buf = ""
126
- self._in = False
127
-
128
- def feed(self, text):
129
- """Yield (is_thinking, chunk) tuples."""
130
- self._buf += text
131
- while self._buf:
132
- prev = len(self._buf)
133
- result = self._step()
134
- if result is not None:
135
- yield result
136
- elif len(self._buf) == prev:
137
- break
138
-
139
- def flush(self):
140
- if self._buf:
141
- yield (self._in, self._buf)
142
- self._buf = ""
143
-
144
- def _step(self):
145
- if self._in:
146
- return self._inside()
147
- return self._outside()
148
-
149
- def _outside(self):
150
- o = self._buf.find(self.OPEN)
151
- c = self._buf.find(self.CLOSE)
152
-
153
- # orphan close tag
154
- if c != -1 and (o == -1 or c < o):
155
- pre = self._buf[:c]
156
- self._buf = self._buf[c + len(self.CLOSE):]
157
- return (False, pre) if pre else None
158
-
159
- if o == -1:
160
- br = self._buf.rfind("<")
161
- if br != -1:
162
- tail = self._buf[br:]
163
- if (
164
- len(tail) < len(self.OPEN)
165
- and self.OPEN.startswith(tail)
166
- ):
167
- emit = self._buf[:br]
168
- self._buf = self._buf[br:]
169
- return (False, emit) if emit else None
170
- emit = self._buf
171
- self._buf = ""
172
- return (False, emit) if emit else None
173
-
174
- pre = self._buf[:o]
175
- self._buf = self._buf[o + len(self.OPEN):]
176
- self._in = True
177
- return (False, pre) if pre else None
178
-
179
- def _inside(self):
180
- c = self._buf.find(self.CLOSE)
181
- if c == -1:
182
- br = self._buf.rfind("<")
183
- if br != -1:
184
- tail = self._buf[br:]
185
- if (
186
- len(tail) < len(self.CLOSE)
187
- and self.CLOSE.startswith(tail)
188
- ):
189
- emit = self._buf[:br]
190
- self._buf = self._buf[br:]
191
- return (True, emit) if emit else None
192
- emit = self._buf
193
- self._buf = ""
194
- return (True, emit) if emit else None
195
-
196
- thinking = self._buf[:c]
197
- self._buf = self._buf[c + len(self.CLOSE):]
198
- self._in = False
199
- return (True, thinking) if thinking else None
200
-
201
- # =========================================================
202
- # SCHEMA FIX (DeepSeek rejects type:null)
203
- # =========================================================
204
-
205
- _VALID_TYPES = {
206
- "object", "array", "string",
207
- "number", "boolean", "integer",
208
- }
209
 
 
210
 
211
- def _fix_schema(schema):
212
- if not isinstance(schema, dict):
213
- return {"type": "object", "properties": {}}
214
- if schema.get("type") not in _VALID_TYPES:
215
- schema = dict(schema)
216
- schema["type"] = "object"
217
- schema.setdefault("properties", {})
218
- return schema
219
-
220
- # =========================================================
221
- # REASONING EXTRACTION
222
- # Reads thinking back from previous assistant messages so
223
- # DeepSeek receives reasoning_content on the next turn.
224
- # =========================================================
225
-
226
- def _extract_reasoning(msg):
227
- rc = msg.get("reasoning_content")
228
- if isinstance(rc, str) and rc:
229
- return rc
230
- content = msg.get("content", "")
231
- if isinstance(content, list):
232
- parts = [
233
- item.get("thinking", "")
234
- for item in content
235
- if isinstance(item, dict)
236
- and item.get("type") == "thinking"
237
- ]
238
- joined = "".join(parts)
239
- if joined:
240
- return joined
241
- return None
242
-
243
- # =========================================================
244
- # SYSTEM HELPER
245
- # =========================================================
246
-
247
- def _system_to_str(raw):
248
- if isinstance(raw, str):
249
- return raw or ""
250
- if isinstance(raw, list):
251
- return "\n".join(
252
- item.get("text", "") if isinstance(item, dict) else str(item)
253
- for item in raw
254
- )
255
- return ""
256
 
257
- # =========================================================
258
- # NORMALIZE MESSAGES
259
- # =========================================================
260
 
261
- def normalize_messages(messages):
 
 
 
262
 
263
- output = []
 
 
264
 
265
- for msg in messages:
 
 
 
 
 
266
 
267
- role = msg.get("role", "user")
268
- content = msg.get("content", "")
269
 
270
- if isinstance(content, str):
271
- entry = {"role": role, "content": content}
272
- if role == "assistant":
273
- rc = _extract_reasoning(msg)
274
- if rc:
275
- entry["reasoning_content"] = rc
276
- output.append(entry)
277
- continue
278
 
279
- if isinstance(content, list):
280
 
281
- text_parts = []
282
- tool_calls = []
283
- tool_results = []
284
 
285
- for item in content:
 
 
 
 
 
 
 
286
 
287
- if not isinstance(item, dict):
288
- text_parts.append(str(item))
289
- continue
290
 
291
- t = item.get("type", "")
292
-
293
- if t == "text":
294
- txt = item.get("text", "")
295
- if txt:
296
- text_parts.append(txt)
297
-
298
- elif t == "thinking":
299
- pass # recovered via _extract_reasoning
300
-
301
- elif t == "tool_use":
302
- tool_calls.append({
303
- "id": item.get(
304
- "id", f"call_{uuid.uuid4().hex[:8]}"
305
- ),
306
- "type": "function",
307
- "function": {
308
- "name": item.get("name", ""),
309
- "arguments": json.dumps(
310
- item.get("input", {})
311
- ),
312
- },
313
- })
314
-
315
- elif t == "tool_result":
316
- rc_c = item.get("content", "")
317
- if isinstance(rc_c, list):
318
- rc_c = "\n".join(
319
- c.get("text", "")
320
- if isinstance(c, dict) else str(c)
321
- for c in rc_c
322
- )
323
- tool_results.append({
324
- "role": "tool",
325
- "tool_call_id": item.get("tool_use_id", ""),
326
- "content": str(rc_c),
327
- })
328
-
329
- if tool_calls:
330
- am = {
331
- "role": "assistant",
332
- "tool_calls": tool_calls,
333
- "content": (
334
- "\n".join(text_parts) if text_parts else None
335
- ),
336
- }
337
- rc = _extract_reasoning(msg)
338
- if rc:
339
- am["reasoning_content"] = rc
340
- output.append(am)
341
 
342
- elif tool_results:
343
- output.extend(tool_results)
 
 
 
 
344
 
345
- else:
346
- entry = {
347
- "role": role,
348
- "content": "\n".join(text_parts),
349
- }
350
- if role == "assistant":
351
- rc = _extract_reasoning(msg)
352
- if rc:
353
- entry["reasoning_content"] = rc
354
- output.append(entry)
355
 
356
- continue
 
 
 
 
357
 
358
- output.append({"role": role, "content": str(content)})
359
-
360
- return output
361
-
362
- # =========================================================
363
- # TOOLS
364
- # =========================================================
365
-
366
- def _filter_tools(tools):
367
- filtered = []
368
- removed = []
369
- for t in tools:
370
- name = t.get("name", "") if isinstance(t, dict) else ""
371
- if name in _BROWSER_TOOLS:
372
- removed.append(name)
373
- else:
374
- filtered.append(t)
375
- if removed:
376
- logger.info("Stripped browser tools: %s", removed)
377
- return filtered
378
-
379
-
380
- def convert_tools(tools):
381
- result = []
382
- for t in tools:
383
- result.append({
384
- "type": "function",
385
- "function": {
386
- "name": t.get("name", ""),
387
- "description": t.get("description", ""),
388
- "parameters": _fix_schema(t.get("input_schema", {})),
389
- },
390
- })
391
- return result
392
 
 
 
 
 
 
 
393
 
394
- def map_tool_choice(raw, has_tools):
395
- if not raw or not has_tools:
396
- return None
397
- if isinstance(raw, dict):
398
- t = raw.get("type", "auto")
399
- if t == "any":
400
- return "required"
401
- if t == "tool":
402
- return {
403
- "type": "function",
404
- "function": {"name": raw.get("name", "")}
405
- }
406
- return t
407
- return raw
408
 
409
- # =========================================================
410
- # CONVERT NORMAL RESPONSE
411
- # =========================================================
412
 
413
- _FINISH_MAP = {"tool_calls": "tool_use", "length": "max_tokens"}
 
 
 
414
 
 
415
 
416
- def convert_response(parsed, model):
417
 
418
- blocks = []
419
- stop_reason = "end_turn"
420
 
421
- try:
422
- choices = parsed.get("choices") or []
423
- if choices:
424
- choice = choices[0]
425
- message = choice.get("message", {})
426
- stop_reason = _FINISH_MAP.get(
427
- choice.get("finish_reason"), "end_turn"
428
- )
429
-
430
- reasoning = message.get("reasoning_content", "")
431
- if reasoning:
432
- blocks.append({"type": "thinking", "thinking": reasoning})
433
-
434
- text = message.get("content") or ""
435
- if text:
436
- blocks.append({"type": "text", "text": text})
437
 
438
- for tc in message.get("tool_calls") or []:
439
- fn = tc.get("function", {})
440
- raw_args = fn.get("arguments", "{}")
441
- try:
442
- args = json.loads(raw_args)
443
- except Exception:
444
- args = {"raw": raw_args}
445
- blocks.append({
446
- "type": "tool_use",
447
- "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
448
- "name": fn.get("name", ""),
449
- "input": args,
450
- })
451
-
452
- except Exception as err:
453
- logger.warning("convert_response: %s", err)
454
-
455
- if not blocks:
456
- blocks.append({"type": "text", "text": ""})
457
-
458
- usage = parsed.get("usage", {})
459
- return {
460
- "id": f"msg_{uuid.uuid4().hex}",
461
- "type": "message",
462
- "role": "assistant",
463
- "model": model,
464
- "content": blocks,
465
- "stop_reason": stop_reason,
466
- "stop_sequence": None,
467
- "usage": {
468
- "input_tokens": usage.get("prompt_tokens", 0),
469
- "output_tokens": usage.get("completion_tokens", 0),
470
- },
471
- }
472
 
473
- # =========================================================
474
- # STREAM CONVERTER
475
- # =========================================================
476
-
477
- _E_MSG_START = "event: message_start\ndata: "
478
- _E_BLOCK_START = "event: content_block_start\ndata: "
479
- _E_BLOCK_DELTA = "event: content_block_delta\ndata: "
480
- _E_BLOCK_STOP = "event: content_block_stop\ndata: "
481
- _E_MSG_DELTA = "event: message_delta\ndata: "
482
- _E_MSG_STOP = "event: message_stop\ndata: "
483
-
484
-
485
- async def stream_convert(response, model):
486
-
487
- msg_id = f"msg_{uuid.uuid4().hex}"
488
- out_tokens = 0
489
- stop_reason = "end_turn"
490
-
491
- thinking_idx = None
492
- text_idx = None
493
- tool_idx = {}
494
- tool_state = {}
495
- next_idx = 0
496
-
497
- parser = _ThinkParser()
498
-
499
- # message_start
500
- yield (
501
- _E_MSG_START
502
- + json.dumps({
503
- "type": "message_start",
504
- "message": {
505
- "id": msg_id, "type": "message",
506
- "role": "assistant", "model": model,
507
- "content": [], "stop_reason": None,
508
- "stop_sequence": None,
509
- "usage": {"input_tokens": 0, "output_tokens": 0},
510
- },
511
- })
512
- + "\n\n"
513
- )
514
- yield 'event: ping\ndata: {"type":"ping"}\n\n'
515
-
516
- # helpers
517
-
518
- def open_thinking():
519
- nonlocal thinking_idx, next_idx
520
- thinking_idx = next_idx
521
- next_idx += 1
522
- return (
523
- _E_BLOCK_START
524
- + json.dumps({
525
- "type": "content_block_start",
526
- "index": thinking_idx,
527
- "content_block": {"type": "thinking", "thinking": ""},
528
- })
529
- + "\n\n"
530
  )
531
 
532
- def delta_thinking(text):
533
- return (
534
- _E_BLOCK_DELTA
535
- + json.dumps({
536
- "type": "content_block_delta",
537
- "index": thinking_idx,
538
- "delta": {"type": "thinking_delta", "thinking": text},
539
- })
540
- + "\n\n"
541
- )
542
 
543
- def open_text():
544
- nonlocal text_idx, next_idx
545
- text_idx = next_idx
546
- next_idx += 1
547
- return (
548
- _E_BLOCK_START
549
- + json.dumps({
550
- "type": "content_block_start",
551
- "index": text_idx,
552
- "content_block": {"type": "text", "text": ""},
553
- })
554
- + "\n\n"
555
- )
556
 
557
- def delta_text(text):
558
- return (
559
- _E_BLOCK_DELTA
560
- + json.dumps({
561
- "type": "content_block_delta",
562
- "index": text_idx,
563
- "delta": {"type": "text_delta", "text": text},
564
- })
565
- + "\n\n"
566
- )
567
 
 
 
 
 
568
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569
 
570
- async for raw in response.aiter_lines():
 
 
 
 
571
 
572
- if not raw or not raw.startswith("data:"):
573
- continue
 
 
574
 
575
- line = raw[5:].strip()
 
 
576
 
577
- if line == "[DONE]":
578
- break
 
 
579
 
580
- try:
581
- chunk = json.loads(line)
582
- except Exception:
583
- continue
584
 
585
- if chunk.get("usage"):
586
- out_tokens = chunk["usage"].get(
587
- "completion_tokens", out_tokens
588
- )
589
 
590
- choices = chunk.get("choices")
591
- if not choices:
592
- continue
593
-
594
- choice = choices[0]
595
- delta = choice.get("delta", {})
596
- finish = choice.get("finish_reason")
597
-
598
- if finish == "tool_calls":
599
- stop_reason = "tool_use"
600
- elif finish == "length":
601
- stop_reason = "max_tokens"
602
-
603
- # reasoning_content field (DeepSeek native)
604
- rc = delta.get("reasoning_content")
605
- if rc:
606
- if thinking_idx is None:
607
- yield open_thinking()
608
- yield delta_thinking(rc)
609
- continue
610
-
611
- # text content — may contain <think> tags
612
- raw_text = delta.get("content") or ""
613
- if raw_text:
614
- for is_thinking, chunk_text in parser.feed(raw_text):
615
- if is_thinking:
616
- if thinking_idx is None:
617
- yield open_thinking()
618
- yield delta_thinking(chunk_text)
619
- else:
620
- if text_idx is None:
621
- yield open_text()
622
- yield delta_text(chunk_text)
623
-
624
- # tool calls
625
- for tc in delta.get("tool_calls") or []:
626
- idx = tc.get("index", 0)
627
-
628
- if idx not in tool_state:
629
- tool_state[idx] = {
630
- "id": tc.get(
631
- "id", f"toolu_{uuid.uuid4().hex[:8]}"
632
- ),
633
- "name": "",
634
- }
635
- blk = next_idx
636
- next_idx += 1
637
- tool_idx[idx] = blk
638
- fn_name = (tc.get("function") or {}).get("name", "")
639
- tool_state[idx]["name"] = fn_name
640
- yield (
641
- _E_BLOCK_START
642
- + json.dumps({
643
- "type": "content_block_start",
644
- "index": blk,
645
- "content_block": {
646
- "type": "tool_use",
647
- "id": tool_state[idx]["id"],
648
- "name": fn_name,
649
- "input": {},
650
- },
651
- })
652
- + "\n\n"
653
- )
654
-
655
- args = (tc.get("function") or {}).get("arguments", "")
656
- if args:
657
- yield (
658
- _E_BLOCK_DELTA
659
- + json.dumps({
660
- "type": "content_block_delta",
661
- "index": tool_idx[idx],
662
- "delta": {
663
- "type": "input_json_delta",
664
- "partial_json": args,
665
- },
666
- })
667
- + "\n\n"
668
- )
669
-
670
- except Exception as err:
671
- logger.warning("stream error: %s", err)
672
-
673
- # flush leftover think-tag buffer
674
- for is_thinking, chunk_text in (parser.flush() or []):
675
- if is_thinking:
676
- if thinking_idx is None:
677
- yield open_thinking()
678
- yield delta_thinking(chunk_text)
679
- else:
680
- if text_idx is None:
681
- yield open_text()
682
- yield delta_text(chunk_text)
683
-
684
- # close blocks
685
- if thinking_idx is not None:
686
- yield (
687
- _E_BLOCK_STOP
688
- + json.dumps({
689
- "type": "content_block_stop",
690
- "index": thinking_idx,
691
- })
692
- + "\n\n"
693
- )
694
 
695
- if text_idx is not None:
696
- yield (
697
- _E_BLOCK_STOP
698
- + json.dumps({
699
- "type": "content_block_stop",
700
- "index": text_idx,
701
- })
702
- + "\n\n"
703
- )
704
 
705
- for _, blk in tool_idx.items():
706
- yield (
707
- _E_BLOCK_STOP
708
- + json.dumps({
709
- "type": "content_block_stop",
710
- "index": blk,
711
- })
712
- + "\n\n"
713
- )
714
 
715
- yield (
716
- _E_MSG_DELTA
717
- + json.dumps({
718
- "type": "message_delta",
719
- "delta": {
720
- "stop_reason": stop_reason,
721
- "stop_sequence": None,
722
- },
723
- "usage": {"output_tokens": out_tokens},
724
- })
725
- + "\n\n"
726
- )
727
 
728
- yield (
729
- _E_MSG_STOP
730
- + json.dumps({"type": "message_stop"})
731
- + "\n\n"
732
- )
733
-
734
- # =========================================================
735
- # CONNECTOR PROMPT
736
- # =========================================================
737
- # Teaches the model which tools it has and forces it to use
738
- # them — this is what makes it behave like real Claude with
739
- # connectors (web search, file system, MCP tools etc.)
740
 
741
- def _build_connector_prompt(tools):
742
- if not tools:
743
- return ""
744
-
745
- names = {t.get("name", "") for t in tools if isinstance(t, dict)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
 
747
- sections = []
 
 
 
 
 
 
 
748
 
749
- # Web search
750
- web = {
751
- "web_search", "search", "brave_search",
752
- "tavily_search", "web_search_tool",
753
- }
754
- if names & web:
755
- sections.append(
756
- "- WEB SEARCH available: call it immediately whenever the user"
757
- " asks about current events, news, prices, docs, or any"
758
- " real-time information. Never say you cannot search."
759
- )
760
 
761
- # File system
762
- fs = {
763
- "read_file", "write_file", "create_file",
764
- "edit_file", "list_directory", "list_files",
765
- "str_replace_editor", "view_file",
766
- }
767
- if names & fs:
768
- sections.append(
769
- "- FILE SYSTEM available: read, write and create files on disk."
770
- " When asked for a document, CSV, JSON or any file — create it."
771
- )
772
 
773
- # Code execution
774
- exe = {
775
- "execute_command", "run_command", "bash",
776
- "shell", "terminal", "execute_code",
777
- }
778
- if names & exe:
779
- sections.append(
780
- "- CODE EXECUTION available: run shell commands directly."
781
- " Use it to install packages, run scripts, or test code."
782
- )
783
 
784
- # MCP / other tools
785
- known = web | fs | exe | _BROWSER_TOOLS | {""}
786
- other = sorted(names - known)
787
- if other:
788
- sections.append(
789
- "- ADDITIONAL TOOLS: " + ", ".join(other) + "."
790
- " Use them whenever they are the most direct solution."
791
- )
792
 
793
- if not sections:
794
- return ""
 
795
 
796
- lines = [
797
- "# Connector capabilities active in this session",
798
- "",
799
- "You have real tools connected. Use them proactively:",
800
- "",
801
- ] + sections + [
802
- "",
803
- "IMPORTANT: always call the relevant tool instead of saying"
804
- " you cannot access external information.",
805
- ]
806
-
807
- return "\n".join(lines)
808
 
 
809
 
810
- # =========================================================
811
  # FASTAPI
812
- # =========================================================
813
-
814
- @asynccontextmanager
815
- async def lifespan(app: FastAPI):
816
- app.state.client = httpx.AsyncClient(
817
- http2=False,
818
- timeout=httpx.Timeout(connect=20, read=300, write=60, pool=60),
819
- limits=httpx.Limits(
820
- max_connections=100,
821
- max_keepalive_connections=20,
822
- ),
823
- follow_redirects=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824
  )
825
- logger.info("Gateway started.")
826
- yield
827
- await app.state.client.aclose()
828
-
829
-
830
- app = FastAPI(lifespan=lifespan)
831
-
832
 
833
  @app.get("/")
834
- @app.head("/")
835
  async def root():
836
- return {"status": "ok", "service": "OpenCode Gateway"}
837
-
 
 
 
 
 
 
 
 
 
 
 
 
838
 
839
  @app.get("/health")
840
  async def health():
841
- return {"status": "healthy"}
842
-
843
-
844
- @app.get("/v1/models")
845
- async def get_models():
846
- return _MODELS_CACHE
847
-
848
- # =========================================================
849
- # MAIN ENDPOINT
850
- # =========================================================
851
-
852
- @app.post("/v1/messages")
853
- @app.post("/v1/chat/completions")
854
- async def messages(
855
- request: Request,
856
- authorization: str = Header(default=None),
857
- ):
858
- req_id = uuid.uuid4().hex[:8]
859
-
860
- if not OPENCODE_API_KEY:
861
- return JSONResponse(
862
- status_code=401,
863
- content={"error": "Missing OPENCODE_API_KEY"},
864
- )
865
-
866
- try:
867
- body = await request.json()
868
- except Exception:
869
- return JSONResponse(
870
- status_code=400,
871
- content={"error": "Invalid JSON"},
872
- )
873
-
874
- requested_model = body.get("model", "claude-sonnet-4-6")
875
- _direct = MODEL_MAP.get(requested_model, DEFAULT_MODEL)
876
- mapped_model = _pick_free_model() if _direct is None else _direct
877
- stream = bool(body.get("stream", False))
878
-
879
- # tools
880
- raw_tools = body.get("tools", [])
881
- clean_tools = _filter_tools(raw_tools)
882
- openai_tools = convert_tools(clean_tools)
883
- tool_choice = map_tool_choice(
884
- body.get("tool_choice"), bool(openai_tools)
885
- )
886
 
887
- # messages
888
- oai_messages = normalize_messages(body.get("messages", []))
 
889
 
890
- # system
891
- sys_str = _system_to_str(body.get("system", ""))
892
- connector_str = _build_connector_prompt(clean_tools)
893
 
894
- parts = [p for p in [connector_str, sys_str] if p]
895
- full_system = "\n\n---\n\n".join(parts)
896
 
897
- if full_system:
898
- oai_messages.insert(0, {"role": "system", "content": full_system})
 
899
 
900
- # payload
901
- payload = {
902
- "model": mapped_model,
903
- "messages": oai_messages,
904
- "temperature": body.get("temperature", 0.7),
905
- "max_tokens": min(body.get("max_tokens", 32000), 64000),
906
- "stream": stream,
907
- }
908
- if openai_tools:
909
- payload["tools"] = openai_tools
910
- if tool_choice is not None:
911
- payload["tool_choice"] = tool_choice
912
-
913
- headers = {**UPSTREAM_HEADERS, "x-api-key": OPENCODE_API_KEY}
914
-
915
- logger.info(
916
- "[%s] %s -> %s | stream=%s | tools=%d",
917
- req_id, requested_model, mapped_model,
918
- stream, len(openai_tools),
919
- )
920
 
921
- client = request.app.state.client
922
- last_error = None
923
 
924
- for attempt in range(1, MAX_RETRIES + 1):
 
 
925
 
926
- try:
 
 
 
 
927
 
928
- # ---------- STREAM ----------
929
- if stream:
930
 
931
- upstream_req = client.build_request(
932
- "POST", UPSTREAM_URL,
933
- json=payload, headers=headers,
934
- )
935
- resp = await client.send(upstream_req, stream=True)
936
-
937
- if resp.status_code != 200:
938
- raw_err = (
939
- await resp.aread()
940
- ).decode(errors="ignore")
941
- logger.warning(
942
- "[%s] fail %d: %s",
943
- req_id, resp.status_code, raw_err[:200],
944
- )
945
- last_error = raw_err
946
- await asyncio.sleep(RETRY_DELAY)
947
- continue
948
 
949
- return StreamingResponse(
950
- stream_convert(resp, requested_model),
951
- media_type="text/event-stream",
952
- headers=STREAM_HEADERS,
953
- )
954
-
955
- # ---------- NORMAL ----------
956
- resp = await client.post(
957
- UPSTREAM_URL, json=payload, headers=headers,
958
- )
959
-
960
- if resp.status_code != 200:
961
- logger.warning(
962
- "[%s] fail %d", req_id, resp.status_code
963
- )
964
- last_error = resp.text
965
- await asyncio.sleep(RETRY_DELAY)
966
- continue
967
-
968
- if not resp.text.strip():
969
- return JSONResponse(
970
- status_code=502,
971
- content={"error": "Empty upstream response"},
972
- )
973
-
974
- try:
975
- parsed = resp.json()
976
- except Exception:
977
- return JSONResponse(
978
- status_code=502,
979
- content={"error": "Invalid upstream JSON"},
980
- )
981
-
982
- return JSONResponse(
983
- content=convert_response(parsed, requested_model)
984
- )
985
 
986
- except Exception as err:
987
- last_error = str(err)
988
- logger.exception("[%s] exception", req_id)
989
- await asyncio.sleep(RETRY_DELAY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
990
 
991
- return JSONResponse(
992
- status_code=502,
993
- content={"error": "Upstream failed", "details": last_error},
994
- )
 
1
  import os
2
  import json
3
+ import time
 
4
  import logging
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from fastapi.responses import StreamingResponse, JSONResponse
8
+ from pydantic import BaseModel
9
+ import g4f
10
+
11
+ logging.basicConfig(level=logging.INFO)
12
+ logger = logging.getLogger("g4f-smart-router")
13
+
14
+ # =====================================================
15
+ # COOKIES
16
+ # =====================================================
17
+ def _load_cookies_raw() -> Dict[str, Any]:
18
+ raw_env = (os.getenv("COOKIES_JSON") or "").strip()
19
+
20
+ if raw_env:
21
+ try:
22
+ return json.loads(raw_env)
23
+ except Exception as e:
24
+ logger.warning(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ try:
27
+ if os.path.exists("cookies.json"):
28
+ with open("cookies.json", "r", encoding="utf-8") as f:
29
+ return json.load(f)
30
+ except Exception as e:
31
+ logger.warning(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
+ return {}
34
 
35
+ def load_cookies() -> str:
36
+ data = _load_cookies_raw()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
+ if not data:
39
+ return "⚠️ No Cookies"
 
40
 
41
+ try:
42
+ from g4f.cookies import set_cookies
43
+ except Exception:
44
+ return "⚠️ Cookies Found"
45
 
46
+ for domain, vals in data.items():
47
+ try:
48
+ dom = domain if "." in domain else f".{domain}.com"
49
 
50
+ if isinstance(vals, list):
51
+ vals = {
52
+ x["name"]: x["value"]
53
+ for x in vals
54
+ if isinstance(x, dict)
55
+ }
56
 
57
+ if isinstance(vals, dict):
58
+ set_cookies(dom, vals)
59
 
60
+ except Exception as e:
61
+ logger.warning(e)
 
 
 
 
 
 
62
 
63
+ return "✅ Cookies Loaded"
64
 
65
+ COOKIE_STATUS = load_cookies()
 
 
66
 
67
+ # =====================================================
68
+ # PROVIDERS
69
+ # =====================================================
70
+ def get_provider(name: str):
71
+ try:
72
+ return getattr(g4f.Provider, name)
73
+ except:
74
+ return None
75
 
76
+ REAL_PROVIDERS: Dict[str, Any] = {
77
+ "Qwen": get_provider("Qwen"),
78
+ }
79
 
80
+ REAL_PROVIDERS = {k: v for k, v in REAL_PROVIDERS.items() if v}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
+ # =====================================================
83
+ # MODELS
84
+ # =====================================================
85
+ PROVIDER_MODELS_FALLBACK: Dict[str, List[str]] = {
86
+ "Qwen": ["Qwen/Qwen3.6-35B-A3B"],
87
+ }
88
 
89
+ CLAUDE_MODEL_MAP: Dict[str, str] = {
90
+ "claude-opus-4-6": "Qwen/Qwen3.6-35B-A3B",
91
+ "claude-sonnet-4-6": "Qwen/Qwen3.6-35B-A3B",
92
+ "claude-haiku-4-5": "Qwen/Qwen3.6-35B-A3B",
93
+ }
 
 
 
 
 
94
 
95
+ # =====================================================
96
+ # MODEL DISCOVERY
97
+ # =====================================================
98
+ def _normalize_model_list(x: Any) -> List[str]:
99
+ out: List[str] = []
100
 
101
+ if x is None:
102
+ return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
+ if isinstance(x, dict):
105
+ out = [str(k) for k in x.keys()]
106
+ elif isinstance(x, (list, tuple, set)):
107
+ out = [str(i) for i in x]
108
+ else:
109
+ out = [str(x)]
110
 
111
+ out = [m.strip() for m in out if m]
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ seen = set()
114
+ uniq = []
 
115
 
116
+ for m in out:
117
+ if m not in seen:
118
+ uniq.append(m)
119
+ seen.add(m)
120
 
121
+ return uniq
122
 
123
+ def discover_provider_models(provider_obj: Any, provider_name: str) -> List[str]:
124
 
125
+ candidates = []
 
126
 
127
+ for attr in (
128
+ "models",
129
+ "model",
130
+ "default_model",
131
+ "available_models",
132
+ "supported_models"
133
+ ):
134
+ try:
135
+ if hasattr(provider_obj, attr):
136
+ candidates += _normalize_model_list(
137
+ getattr(provider_obj, attr)
138
+ )
139
+ except:
140
+ pass
 
 
141
 
142
+ for fn in ("get_models", "models_list", "list_models"):
143
+ try:
144
+ if hasattr(provider_obj, fn):
145
+ candidates += _normalize_model_list(
146
+ getattr(provider_obj, fn)()
147
+ )
148
+ except:
149
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
+ if not candidates:
152
+ candidates = PROVIDER_MODELS_FALLBACK.get(
153
+ provider_name,
154
+ ["Qwen/Qwen3.6-35B-A3B"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  )
156
 
157
+ seen = set()
158
+ uniq = []
 
 
 
 
 
 
 
 
159
 
160
+ for m in candidates:
161
+ if m not in seen:
162
+ uniq.append(m)
163
+ seen.add(m)
 
 
 
 
 
 
 
 
 
164
 
165
+ return uniq
 
 
 
 
 
 
 
 
 
166
 
167
+ # =====================================================
168
+ # STREAM CLEANER
169
+ # =====================================================
170
+ def clean_stream(chunk):
171
  try:
172
+ if isinstance(chunk, dict):
173
+ if 'choices' in chunk and chunk['choices']:
174
+ delta = chunk['choices'][0].get('delta', {})
175
+ if 'content' in delta:
176
+ return delta['content']
177
+ if 'text' in delta:
178
+ return delta['text']
179
+ if 'content' in chunk:
180
+ return chunk['content']
181
+ if 'text' in chunk:
182
+ return chunk['text']
183
+ return ""
184
+
185
+ if isinstance(chunk, str):
186
+ if chunk.strip().startswith("{") and chunk.strip().endswith("}"):
187
+ try:
188
+ data = json.loads(chunk)
189
+ if 'choices' in data and data['choices']:
190
+ delta = data['choices'][0].get('delta', {})
191
+ if 'content' in delta:
192
+ return delta['content']
193
+ if 'content' in data:
194
+ return data['content']
195
+ if 'text' in data:
196
+ return data['text']
197
+ except:
198
+ pass
199
+
200
+ chunk = chunk.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', ' ')
201
+ chunk = chunk.replace('\\"', '"').replace("\\'", "'")
202
+ return chunk
203
+
204
+ return str(chunk)
205
+ except Exception as e:
206
+ logger.warning(f"خطأ في clean_stream: {e}")
207
+ return ""
208
 
209
+ # =====================================================
210
+ # CACHE
211
+ # =====================================================
212
+ CACHE = {}
213
+ CACHE_TS = {}
214
 
215
+ def _cache_get(key):
216
+ if key not in CACHE:
217
+ return None
218
+ return CACHE[key]
219
 
220
+ def _cache_set(key, val):
221
+ CACHE[key] = val
222
+ CACHE_TS[key] = time.time()
223
 
224
+ # =====================================================
225
+ # CHAT LOGIC
226
+ # =====================================================
227
+ _PROVIDER_MODEL_CACHE = {}
228
 
229
+ def ask(message: str, history, provider_name: str, model_name: str):
230
+ history = history or []
231
+ message = (message or "").strip()
 
232
 
233
+ if not message:
234
+ yield ""
235
+ return
 
236
 
237
+ # تحويل نماذج Claude الوهمية إلى Qwen3.6-35B-A3B
238
+ model_name = CLAUDE_MODEL_MAP.get(model_name, "Qwen/Qwen3.6-35B-A3B")
239
+ provider_name = "Qwen"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
+ key = f"{provider_name}|{model_name}|{message}"
 
 
 
 
 
 
 
 
242
 
243
+ cached = _cache_get(key)
 
 
 
 
 
 
 
 
244
 
245
+ if cached is not None:
246
+ yield cached
247
+ return
 
 
 
 
 
 
 
 
 
248
 
249
+ msgs = []
 
 
 
 
 
 
 
 
 
 
 
250
 
251
+ try:
252
+ if history:
253
+ if isinstance(history[0], dict):
254
+ for item in history[-40:]:
255
+ role = item.get("role")
256
+ content = item.get("content")
257
+ if role and content:
258
+ msgs.append({
259
+ "role": str(role),
260
+ "content": str(content)
261
+ })
262
+ else:
263
+ for item in history[-20:]:
264
+ if isinstance(item, (list, tuple)) and len(item) == 2:
265
+ u = item[0]
266
+ a = item[1]
267
+ if u:
268
+ msgs.append({
269
+ "role": "user",
270
+ "content": str(u)
271
+ })
272
+ if a:
273
+ msgs.append({
274
+ "role": "assistant",
275
+ "content": str(a)
276
+ })
277
+ except Exception as e:
278
+ logger.warning(e)
279
+
280
+ msgs.append({
281
+ "role": "user",
282
+ "content": message
283
+ })
284
+
285
+ pobj = REAL_PROVIDERS.get("Qwen")
286
+
287
+ if not pobj:
288
+ yield "❌ Qwen provider not available."
289
+ return
290
+
291
+ if "Qwen" not in _PROVIDER_MODEL_CACHE:
292
+ _PROVIDER_MODEL_CACHE["Qwen"] = discover_provider_models(pobj, "Qwen")
293
+
294
+ model_candidates = ["Qwen/Qwen3.6-35B-A3B"] + [
295
+ x for x in _PROVIDER_MODEL_CACHE["Qwen"]
296
+ if x != "Qwen/Qwen3.6-35B-A3B"
297
+ ]
298
 
299
+ for m in model_candidates[:12]:
300
+ try:
301
+ stream = g4f.ChatCompletion.create(
302
+ model=m,
303
+ provider=pobj,
304
+ messages=msgs,
305
+ stream=True
306
+ )
307
 
308
+ text = ""
 
 
 
 
 
 
 
 
 
 
309
 
310
+ for chunk in stream:
311
+ c = clean_stream(chunk)
 
 
 
 
 
 
 
 
 
312
 
313
+ if c is None or c == "":
314
+ continue
 
 
 
 
 
 
 
 
315
 
316
+ text += c
317
+ yield text
 
 
 
 
 
 
318
 
319
+ if text.strip():
320
+ _cache_set(key, text)
321
+ return
322
 
323
+ except Exception as e:
324
+ logger.warning(e)
325
+ continue
 
 
 
 
 
 
 
 
 
326
 
327
+ yield "❌ Failed with Qwen provider."
328
 
329
+ # =====================================================
330
  # FASTAPI
331
+ # =====================================================
332
+
333
+ app = FastAPI(title="G4F API", description="G4F Smart Router API")
334
+
335
+ API_KEY = os.getenv("API_KEY", "mysecretkey123")
336
+
337
+ class ChatRequest(BaseModel):
338
+ message: str
339
+ provider: str = "Qwen"
340
+ model: str = "claude-sonnet-4-6"
341
+ history: List[Any] = []
342
+
343
+ # =====================================================
344
+ # MCP SUPPORT
345
+ # =====================================================
346
+ class MCPToolCall(BaseModel):
347
+ tool: str
348
+ parameters: Dict[str, Any] = {}
349
+
350
+ class MCPRequest(BaseModel):
351
+ messages: List[Dict[str, Any]]
352
+ tools: List[Dict[str, Any]] = []
353
+ model: str = "claude-sonnet-4-6"
354
+
355
+ def verify_api_key(request: Request):
356
+ auth = request.headers.get("Authorization")
357
+ if auth and auth.startswith("Bearer "):
358
+ key = auth.replace("Bearer ", "")
359
+ if key == API_KEY:
360
+ return True
361
+
362
+ x_api_key = request.headers.get("X-API-Key")
363
+ if x_api_key and x_api_key == API_KEY:
364
+ return True
365
+
366
+ raise HTTPException(
367
+ status_code=401,
368
+ detail="Invalid or missing API key. Use 'Authorization: Bearer KEY' or 'X-API-Key: KEY'"
369
  )
 
 
 
 
 
 
 
370
 
371
  @app.get("/")
 
372
  async def root():
373
+ return {
374
+ "message": "G4F API is running",
375
+ "endpoints": {
376
+ "GET /": "هذه الصفحة",
377
+ "GET /health": "التحقق من صحة الخادم",
378
+ "GET /providers": "قائمة المزودين والنماذج (يتطلب مفتاح)",
379
+ "POST /chat": "إرسال رسالة والحصول على رد (يتطلب مفتاح)",
380
+ "POST /chat/stream": "إرسال رسالة والحصول على رد متدفق (يتطلب مفتاح)",
381
+ "POST /v1/messages": "MCP / Claude-compatible endpoint (يتطلب مفتاح)",
382
+ "GET /v1/models": "قائمة النماذج بصيغة OpenAI (يتطلب مفتاح)"
383
+ },
384
+ "authentication": "Bearer YOUR_API_KEY or X-API-Key: YOUR_API_KEY",
385
+ "status": "✅ Server is working"
386
+ }
387
 
388
  @app.get("/health")
389
  async def health():
390
+ return {"status": "ok", "cookies": COOKIE_STATUS, "providers": list(REAL_PROVIDERS.keys())}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
+ @app.post("/chat")
393
+ async def chat(request: Request, chat_req: ChatRequest):
394
+ verify_api_key(request)
395
 
396
+ full_response = ""
397
+ for chunk in ask(chat_req.message, chat_req.history, chat_req.provider, chat_req.model):
398
+ full_response = chunk
399
 
400
+ return JSONResponse(content={"response": full_response})
 
401
 
402
+ @app.post("/chat/stream")
403
+ async def chat_stream(request: Request, chat_req: ChatRequest):
404
+ verify_api_key(request)
405
 
406
+ async def generate():
407
+ for chunk in ask(chat_req.message, chat_req.history, chat_req.provider, chat_req.model):
408
+ yield f"data: {json.dumps({'chunk': chunk})}\n\n"
409
+ yield "data: [DONE]\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
 
411
+ return StreamingResponse(generate(), media_type="text/event-stream")
 
412
 
413
+ @app.get("/providers")
414
+ async def get_providers(request: Request):
415
+ verify_api_key(request)
416
 
417
+ providers_info = {}
418
+ for pname, pobj in REAL_PROVIDERS.items():
419
+ if pname not in _PROVIDER_MODEL_CACHE:
420
+ _PROVIDER_MODEL_CACHE[pname] = discover_provider_models(pobj, pname)
421
+ providers_info[pname] = _PROVIDER_MODEL_CACHE[pname]
422
 
423
+ return JSONResponse(content={"providers": providers_info})
 
424
 
425
+ # =====================================================
426
+ # MCP / Claude-compatible endpoints
427
+ # =====================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
 
429
+ @app.get("/v1/models")
430
+ async def list_models(request: Request):
431
+ verify_api_key(request)
432
+ models = [
433
+ {"id": "claude-opus-4-6", "object": "model", "owned_by": "anthropic"},
434
+ {"id": "claude-sonnet-4-6", "object": "model", "owned_by": "anthropic"},
435
+ {"id": "claude-haiku-4-5", "object": "model", "owned_by": "anthropic"},
436
+ ]
437
+ return JSONResponse(content={"object": "list", "data": models})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
+ @app.post("/v1/messages")
440
+ async def mcp_messages(request: Request):
441
+ verify_api_key(request)
442
+
443
+ body = await request.json()
444
+ model = body.get("model", "claude-sonnet-4-6")
445
+ messages = body.get("messages", [])
446
+ stream = body.get("stream", False)
447
+ max_tokens = body.get("max_tokens", 4096)
448
+
449
+ # استخراج آخر رسالة كـ message والباقي كـ history
450
+ if not messages:
451
+ raise HTTPException(status_code=400, detail="messages is required")
452
+
453
+ last_msg = messages[-1]
454
+ history = messages[:-1]
455
+
456
+ # دعم content كـ string أو list
457
+ if isinstance(last_msg.get("content"), list):
458
+ user_text = " ".join(
459
+ block.get("text", "")
460
+ for block in last_msg["content"]
461
+ if isinstance(block, dict) and block.get("type") == "text"
462
+ )
463
+ else:
464
+ user_text = last_msg.get("content", "")
465
+
466
+ if stream:
467
+ async def generate_stream():
468
+ full = ""
469
+ for chunk in ask(user_text, history, "Qwen", model):
470
+ delta = chunk[len(full):]
471
+ full = chunk
472
+ if delta:
473
+ event = {
474
+ "type": "content_block_delta",
475
+ "delta": {"type": "text_delta", "text": delta}
476
+ }
477
+ yield f"data: {json.dumps(event)}\n\n"
478
+ yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
479
+
480
+ return StreamingResponse(generate_stream(), media_type="text/event-stream")
481
+
482
+ else:
483
+ full_response = ""
484
+ for chunk in ask(user_text, history, "Qwen", model):
485
+ full_response = chunk
486
+
487
+ return JSONResponse(content={
488
+ "id": f"msg_{int(time.time())}",
489
+ "type": "message",
490
+ "role": "assistant",
491
+ "model": model,
492
+ "content": [{"type": "text", "text": full_response}],
493
+ "stop_reason": "end_turn",
494
+ "usage": {"input_tokens": 0, "output_tokens": 0}
495
+ })
496
 
497
+ if __name__ == "__main__":
498
+ import uvicorn
499
+ port = int(os.getenv("PORT", "7860"))
500
+ uvicorn.run(app, host="0.0.0.0", port=port)