bahi-bh commited on
Commit
8e0b70a
·
verified ·
1 Parent(s): 220c6a7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +127 -378
app.py CHANGED
@@ -10,9 +10,8 @@ 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"
@@ -20,13 +19,12 @@ logging.basicConfig(
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",
@@ -36,7 +34,7 @@ UPSTREAM_HEADERS = {
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
 
@@ -47,42 +45,31 @@ STREAM_HEADERS = {
47
  }
48
 
49
  # =========================================================
50
- # MODEL MAP
51
  # =========================================================
52
-
53
- # Free models pool — used for round-robin on each request
54
  _FREE_POOL = [
55
  "deepseek-v4-flash-free",
56
- "qwen3.6-plus-free",
57
- "minimax-m2.5-free",
58
- "nemotron-3-super-free",
59
- "big-pickle",
60
  ]
61
  _pool_idx = 0
62
 
63
-
64
  def _pick_free_model():
65
- """Round-robin across free models to avoid hitting one limit."""
66
  global _pool_idx
67
  model = _FREE_POOL[_pool_idx % len(_FREE_POOL)]
68
  _pool_idx += 1
69
  return model
70
 
71
-
72
  MODEL_MAP = {
73
- # Claude models -> round-robin free pool (resolved at request time)
74
- "claude-sonnet-4-6": None,
75
  "claude-sonnet-4-5": None,
76
  "claude-haiku-4-6": None,
77
  "claude-haiku-4-5": None,
78
  "claude-haiku-4-5-20251001": None,
79
  "claude-opus-4-6": "nemotron-3-super-free",
80
- # Direct aliases
81
  "deepseek-v4-flash-free": "deepseek-v4-flash-free",
82
  "nemotron-3-super-free": "nemotron-3-super-free",
83
- "qwen3.6-plus-free": "qwen3.6-plus-free",
84
- "minimax-m2.5-free": "minimax-m2.5-free",
85
- "big-pickle": "big-pickle",
86
  }
87
 
88
  DEFAULT_MODEL = "deepseek-v4-flash-free"
@@ -90,37 +77,32 @@ DEFAULT_MODEL = "deepseek-v4-flash-free"
90
  _MODELS_CACHE = {
91
  "object": "list",
92
  "data": [
93
- {
94
- "id": m,
95
- "object": "model",
96
- "created": 1710000000,
97
- "owned_by": "gateway"
98
- }
99
  for m in MODEL_MAP
100
  ],
101
  }
102
 
103
  # =========================================================
104
- # BROWSER TOOLS — strip before sending to DeepSeek
105
  # =========================================================
106
-
107
- _BROWSER_TOOLS = {
108
- "list_connected_browsers", "tabs_context_mcp",
109
- "browser_action", "screenshot", "puppeteer",
110
- "open_browser", "web_browser", "navigate",
111
- "click", "type_text", "get_page_content",
112
- "scroll", "wait_for_element",
113
- }
 
 
 
 
 
114
 
115
  # =========================================================
116
  # THINK-TAG PARSER
117
- # Parses <think>…</think> tags from streaming text.
118
- # Some models wrap reasoning in tags instead of a field.
119
- # Ported from github.com/Alishahryar1/free-claude-code
120
  # =========================================================
121
-
122
  class _ThinkParser:
123
-
124
  OPEN = "<think>"
125
  CLOSE = "</think>"
126
 
@@ -129,7 +111,6 @@ class _ThinkParser:
129
  self._in = False
130
 
131
  def feed(self, text):
132
- """Yield (is_thinking, chunk) tuples."""
133
  self._buf += text
134
  while self._buf:
135
  prev = len(self._buf)
@@ -153,7 +134,6 @@ class _ThinkParser:
153
  o = self._buf.find(self.OPEN)
154
  c = self._buf.find(self.CLOSE)
155
 
156
- # orphan close tag
157
  if c != -1 and (o == -1 or c < o):
158
  pre = self._buf[:c]
159
  self._buf = self._buf[c + len(self.CLOSE):]
@@ -163,10 +143,7 @@ class _ThinkParser:
163
  br = self._buf.rfind("<")
164
  if br != -1:
165
  tail = self._buf[br:]
166
- if (
167
- len(tail) < len(self.OPEN)
168
- and self.OPEN.startswith(tail)
169
- ):
170
  emit = self._buf[:br]
171
  self._buf = self._buf[br:]
172
  return (False, emit) if emit else None
@@ -185,10 +162,7 @@ class _ThinkParser:
185
  br = self._buf.rfind("<")
186
  if br != -1:
187
  tail = self._buf[br:]
188
- if (
189
- len(tail) < len(self.CLOSE)
190
- and self.CLOSE.startswith(tail)
191
- ):
192
  emit = self._buf[:br]
193
  self._buf = self._buf[br:]
194
  return (True, emit) if emit else None
@@ -202,14 +176,9 @@ class _ThinkParser:
202
  return (True, thinking) if thinking else None
203
 
204
  # =========================================================
205
- # SCHEMA FIX (DeepSeek rejects type:null)
206
  # =========================================================
207
-
208
- _VALID_TYPES = {
209
- "object", "array", "string",
210
- "number", "boolean", "integer",
211
- }
212
-
213
 
214
  def _fix_schema(schema):
215
  if not isinstance(schema, dict):
@@ -221,11 +190,8 @@ def _fix_schema(schema):
221
  return schema
222
 
223
  # =========================================================
224
- # REASONING EXTRACTION
225
- # Reads thinking back from previous assistant messages so
226
- # DeepSeek receives reasoning_content on the next turn.
227
  # =========================================================
228
-
229
  def _extract_reasoning(msg):
230
  rc = msg.get("reasoning_content")
231
  if isinstance(rc, str) and rc:
@@ -235,18 +201,13 @@ def _extract_reasoning(msg):
235
  parts = [
236
  item.get("thinking", "")
237
  for item in content
238
- if isinstance(item, dict)
239
- and item.get("type") == "thinking"
240
  ]
241
  joined = "".join(parts)
242
  if joined:
243
  return joined
244
  return None
245
 
246
- # =========================================================
247
- # SYSTEM HELPER
248
- # =========================================================
249
-
250
  def _system_to_str(raw):
251
  if isinstance(raw, str):
252
  return raw or ""
@@ -257,16 +218,9 @@ def _system_to_str(raw):
257
  )
258
  return ""
259
 
260
- # =========================================================
261
- # NORMALIZE MESSAGES
262
- # =========================================================
263
-
264
  def normalize_messages(messages):
265
-
266
  output = []
267
-
268
  for msg in messages:
269
-
270
  role = msg.get("role", "user")
271
  content = msg.get("content", "")
272
 
@@ -280,47 +234,34 @@ def normalize_messages(messages):
280
  continue
281
 
282
  if isinstance(content, list):
283
-
284
  text_parts = []
285
  tool_calls = []
286
  tool_results = []
287
 
288
  for item in content:
289
-
290
  if not isinstance(item, dict):
291
  text_parts.append(str(item))
292
  continue
293
 
294
  t = item.get("type", "")
295
-
296
  if t == "text":
297
  txt = item.get("text", "")
298
  if txt:
299
  text_parts.append(txt)
300
-
301
- elif t == "thinking":
302
- pass # recovered via _extract_reasoning
303
-
304
  elif t == "tool_use":
305
  tool_calls.append({
306
- "id": item.get(
307
- "id", f"call_{uuid.uuid4().hex[:8]}"
308
- ),
309
  "type": "function",
310
  "function": {
311
  "name": item.get("name", ""),
312
- "arguments": json.dumps(
313
- item.get("input", {})
314
- ),
315
  },
316
  })
317
-
318
  elif t == "tool_result":
319
  rc_c = item.get("content", "")
320
  if isinstance(rc_c, list):
321
  rc_c = "\n".join(
322
- c.get("text", "")
323
- if isinstance(c, dict) else str(c)
324
  for c in rc_c
325
  )
326
  tool_results.append({
@@ -333,56 +274,35 @@ def normalize_messages(messages):
333
  am = {
334
  "role": "assistant",
335
  "tool_calls": tool_calls,
336
- "content": (
337
- "\n".join(text_parts) if text_parts else None
338
- ),
339
  }
340
  rc = _extract_reasoning(msg)
341
  if rc:
342
  am["reasoning_content"] = rc
343
  output.append(am)
344
-
345
  elif tool_results:
346
  output.extend(tool_results)
347
-
348
  else:
349
- entry = {
350
- "role": role,
351
- "content": "\n".join(text_parts),
352
- }
353
  if role == "assistant":
354
  rc = _extract_reasoning(msg)
355
  if rc:
356
  entry["reasoning_content"] = rc
357
  output.append(entry)
358
-
359
  continue
360
 
361
  output.append({"role": role, "content": str(content)})
362
-
363
  return output
364
 
365
  # =========================================================
366
- # TOOLS
367
  # =========================================================
368
-
369
- def _filter_tools(tools):
370
- filtered = []
371
- removed = []
372
- for t in tools:
373
- name = t.get("name", "") if isinstance(t, dict) else ""
374
- if name in _BROWSER_TOOLS:
375
- removed.append(name)
376
- else:
377
- filtered.append(t)
378
- if removed:
379
- logger.info("Stripped browser tools: %s", removed)
380
- return filtered
381
-
382
-
383
  def convert_tools(tools):
 
384
  result = []
385
  for t in tools:
 
 
386
  result.append({
387
  "type": "function",
388
  "function": {
@@ -393,7 +313,6 @@ def convert_tools(tools):
393
  })
394
  return result
395
 
396
-
397
  def map_tool_choice(raw, has_tools):
398
  if not raw or not has_tools:
399
  return None
@@ -402,33 +321,24 @@ def map_tool_choice(raw, has_tools):
402
  if t == "any":
403
  return "required"
404
  if t == "tool":
405
- return {
406
- "type": "function",
407
- "function": {"name": raw.get("name", "")}
408
- }
409
  return t
410
  return raw
411
 
412
  # =========================================================
413
- # CONVERT NORMAL RESPONSE
414
  # =========================================================
415
-
416
  _FINISH_MAP = {"tool_calls": "tool_use", "length": "max_tokens"}
417
 
418
-
419
  def convert_response(parsed, model):
420
-
421
  blocks = []
422
  stop_reason = "end_turn"
423
-
424
  try:
425
  choices = parsed.get("choices") or []
426
  if choices:
427
  choice = choices[0]
428
  message = choice.get("message", {})
429
- stop_reason = _FINISH_MAP.get(
430
- choice.get("finish_reason"), "end_turn"
431
- )
432
 
433
  reasoning = message.get("reasoning_content", "")
434
  if reasoning:
@@ -451,9 +361,8 @@ def convert_response(parsed, model):
451
  "name": fn.get("name", ""),
452
  "input": args,
453
  })
454
-
455
  except Exception as err:
456
- logger.warning("convert_response: %s", err)
457
 
458
  if not blocks:
459
  blocks.append({"type": "text", "text": ""})
@@ -474,9 +383,8 @@ def convert_response(parsed, model):
474
  }
475
 
476
  # =========================================================
477
- # STREAM CONVERTER
478
  # =========================================================
479
-
480
  _E_MSG_START = "event: message_start\ndata: "
481
  _E_BLOCK_START = "event: content_block_start\ndata: "
482
  _E_BLOCK_DELTA = "event: content_block_delta\ndata: "
@@ -484,9 +392,7 @@ _E_BLOCK_STOP = "event: content_block_stop\ndata: "
484
  _E_MSG_DELTA = "event: message_delta\ndata: "
485
  _E_MSG_STOP = "event: message_stop\ndata: "
486
 
487
-
488
  async def stream_convert(response, model):
489
-
490
  msg_id = f"msg_{uuid.uuid4().hex}"
491
  out_tokens = 0
492
  stop_reason = "end_turn"
@@ -499,96 +405,61 @@ async def stream_convert(response, model):
499
 
500
  parser = _ThinkParser()
501
 
502
- # message_start
503
- yield (
504
- _E_MSG_START
505
- + json.dumps({
506
- "type": "message_start",
507
- "message": {
508
- "id": msg_id, "type": "message",
509
- "role": "assistant", "model": model,
510
- "content": [], "stop_reason": None,
511
- "stop_sequence": None,
512
- "usage": {"input_tokens": 0, "output_tokens": 0},
513
- },
514
- })
515
- + "\n\n"
516
- )
517
  yield 'event: ping\ndata: {"type":"ping"}\n\n'
518
 
519
- # helpers
520
-
521
  def open_thinking():
522
  nonlocal thinking_idx, next_idx
523
  thinking_idx = next_idx
524
  next_idx += 1
525
- return (
526
- _E_BLOCK_START
527
- + json.dumps({
528
- "type": "content_block_start",
529
- "index": thinking_idx,
530
- "content_block": {"type": "thinking", "thinking": ""},
531
- })
532
- + "\n\n"
533
- )
534
 
535
  def delta_thinking(text):
536
- return (
537
- _E_BLOCK_DELTA
538
- + json.dumps({
539
- "type": "content_block_delta",
540
- "index": thinking_idx,
541
- "delta": {"type": "thinking_delta", "thinking": text},
542
- })
543
- + "\n\n"
544
- )
545
 
546
  def open_text():
547
  nonlocal text_idx, next_idx
548
  text_idx = next_idx
549
  next_idx += 1
550
- return (
551
- _E_BLOCK_START
552
- + json.dumps({
553
- "type": "content_block_start",
554
- "index": text_idx,
555
- "content_block": {"type": "text", "text": ""},
556
- })
557
- + "\n\n"
558
- )
559
 
560
  def delta_text(text):
561
- return (
562
- _E_BLOCK_DELTA
563
- + json.dumps({
564
- "type": "content_block_delta",
565
- "index": text_idx,
566
- "delta": {"type": "text_delta", "text": text},
567
- })
568
- + "\n\n"
569
- )
570
 
571
  try:
572
-
573
  async for raw in response.aiter_lines():
574
-
575
  if not raw or not raw.startswith("data:"):
576
  continue
577
-
578
  line = raw[5:].strip()
579
-
580
  if line == "[DONE]":
581
  break
582
-
583
  try:
584
  chunk = json.loads(line)
585
  except Exception:
586
  continue
587
 
588
  if chunk.get("usage"):
589
- out_tokens = chunk["usage"].get(
590
- "completion_tokens", out_tokens
591
- )
592
 
593
  choices = chunk.get("choices")
594
  if not choices:
@@ -603,7 +474,6 @@ async def stream_convert(response, model):
603
  elif finish == "length":
604
  stop_reason = "max_tokens"
605
 
606
- # reasoning_content field (DeepSeek native)
607
  rc = delta.get("reasoning_content")
608
  if rc:
609
  if thinking_idx is None:
@@ -611,7 +481,6 @@ async def stream_convert(response, model):
611
  yield delta_thinking(rc)
612
  continue
613
 
614
- # text content — may contain <think> tags
615
  raw_text = delta.get("content") or ""
616
  if raw_text:
617
  for is_thinking, chunk_text in parser.feed(raw_text):
@@ -624,209 +493,125 @@ async def stream_convert(response, model):
624
  yield open_text()
625
  yield delta_text(chunk_text)
626
 
627
- # tool calls
628
  for tc in delta.get("tool_calls") or []:
629
  idx = tc.get("index", 0)
630
-
631
  if idx not in tool_state:
632
- tool_state[idx] = {
633
- "id": tc.get(
634
- "id", f"toolu_{uuid.uuid4().hex[:8]}"
635
- ),
636
- "name": "",
637
- }
638
  blk = next_idx
639
  next_idx += 1
640
  tool_idx[idx] = blk
641
  fn_name = (tc.get("function") or {}).get("name", "")
642
  tool_state[idx]["name"] = fn_name
643
- yield (
644
- _E_BLOCK_START
645
- + json.dumps({
646
- "type": "content_block_start",
647
- "index": blk,
648
- "content_block": {
649
- "type": "tool_use",
650
- "id": tool_state[idx]["id"],
651
- "name": fn_name,
652
- "input": {},
653
- },
654
- })
655
- + "\n\n"
656
- )
657
 
658
  args = (tc.get("function") or {}).get("arguments", "")
659
  if args:
660
- yield (
661
- _E_BLOCK_DELTA
662
- + json.dumps({
663
- "type": "content_block_delta",
664
- "index": tool_idx[idx],
665
- "delta": {
666
- "type": "input_json_delta",
667
- "partial_json": args,
668
- },
669
- })
670
- + "\n\n"
671
- )
672
 
673
  except Exception as err:
674
- logger.warning("stream error: %s", err)
675
 
676
- # flush leftover think-tag buffer
677
  for is_thinking, chunk_text in (parser.flush() or []):
678
  if is_thinking:
679
- if thinking_idx is None:
680
- yield open_thinking()
681
  yield delta_thinking(chunk_text)
682
  else:
683
- if text_idx is None:
684
- yield open_text()
685
  yield delta_text(chunk_text)
686
 
687
- # close blocks
688
  if thinking_idx is not None:
689
- yield (
690
- _E_BLOCK_STOP
691
- + json.dumps({
692
- "type": "content_block_stop",
693
- "index": thinking_idx,
694
- })
695
- + "\n\n"
696
- )
697
-
698
  if text_idx is not None:
699
- yield (
700
- _E_BLOCK_STOP
701
- + json.dumps({
702
- "type": "content_block_stop",
703
- "index": text_idx,
704
- })
705
- + "\n\n"
706
- )
707
-
708
  for _, blk in tool_idx.items():
709
- yield (
710
- _E_BLOCK_STOP
711
- + json.dumps({
712
- "type": "content_block_stop",
713
- "index": blk,
714
- })
715
- + "\n\n"
716
- )
717
 
718
- yield (
719
- _E_MSG_DELTA
720
- + json.dumps({
721
- "type": "message_delta",
722
- "delta": {
723
- "stop_reason": stop_reason,
724
- "stop_sequence": None,
725
- },
726
- "usage": {"output_tokens": out_tokens},
727
- })
728
- + "\n\n"
729
- )
730
 
731
- yield (
732
- _E_MSG_STOP
733
- + json.dumps({"type": "message_stop"})
734
- + "\n\n"
735
- )
736
 
737
  # =========================================================
738
- # FASTAPI
739
  # =========================================================
740
-
741
  @asynccontextmanager
742
  async def lifespan(app: FastAPI):
743
  app.state.client = httpx.AsyncClient(
744
  http2=False,
745
  timeout=httpx.Timeout(connect=20, read=300, write=60, pool=60),
746
- limits=httpx.Limits(
747
- max_connections=100,
748
- max_keepalive_connections=20,
749
- ),
750
  follow_redirects=True,
751
  )
752
- logger.info("Gateway started.")
753
  yield
754
  await app.state.client.aclose()
755
 
756
-
757
  app = FastAPI(lifespan=lifespan)
758
 
759
-
760
  @app.get("/")
761
- @app.head("/")
762
- async def root():
763
- return {"status": "ok", "service": "OpenCode Gateway"}
764
-
765
-
766
  @app.get("/health")
767
  async def health():
768
- return {"status": "healthy"}
769
-
770
 
771
  @app.get("/v1/models")
772
  async def get_models():
773
  return _MODELS_CACHE
774
 
775
  # =========================================================
776
- # MAIN ENDPOINT
777
  # =========================================================
778
-
779
  @app.post("/v1/messages")
780
  @app.post("/v1/chat/completions")
781
- async def messages(
782
- request: Request,
783
- authorization: str = Header(default=None),
784
- ):
785
  req_id = uuid.uuid4().hex[:8]
786
 
787
  if not OPENCODE_API_KEY:
788
- return JSONResponse(
789
- status_code=401,
790
- content={"error": "Missing OPENCODE_API_KEY"},
791
- )
792
 
793
  try:
794
  body = await request.json()
795
  except Exception:
796
- return JSONResponse(
797
- status_code=400,
798
- content={"error": "Invalid JSON"},
799
- )
800
 
801
  requested_model = body.get("model", "claude-sonnet-4-6")
802
  _direct = MODEL_MAP.get(requested_model, DEFAULT_MODEL)
803
  mapped_model = _pick_free_model() if _direct is None else _direct
804
  stream = bool(body.get("stream", False))
805
 
806
- # tools
807
  raw_tools = body.get("tools", [])
808
- clean_tools = _filter_tools(raw_tools)
809
- openai_tools = convert_tools(clean_tools)
810
- tool_choice = map_tool_choice(
811
- body.get("tool_choice"), bool(openai_tools)
812
- )
813
 
814
- # messages
815
  oai_messages = normalize_messages(body.get("messages", []))
816
 
817
- # system
818
- sys_str = _system_to_str(body.get("system", ""))
819
- if sys_str:
820
- oai_messages.insert(0, {"role": "system", "content": sys_str})
 
 
 
 
 
821
 
822
- # payload
823
  payload = {
824
  "model": mapped_model,
825
  "messages": oai_messages,
826
- "temperature": body.get("temperature", 0.7),
827
- "max_tokens": min(body.get("max_tokens", 32000), 64000),
828
  "stream": stream,
829
  }
 
830
  if openai_tools:
831
  payload["tools"] = openai_tools
832
  if tool_choice is not None:
@@ -834,36 +619,21 @@ async def messages(
834
 
835
  headers = {**UPSTREAM_HEADERS, "x-api-key": OPENCODE_API_KEY}
836
 
837
- logger.info(
838
- "[%s] %s -> %s | stream=%s | tools=%d",
839
- req_id, requested_model, mapped_model,
840
- stream, len(openai_tools),
841
- )
842
 
843
- client = request.app.state.client
844
  last_error = None
845
 
846
  for attempt in range(1, MAX_RETRIES + 1):
847
-
848
  try:
849
-
850
- # ---------- STREAM ----------
851
  if stream:
852
-
853
- upstream_req = client.build_request(
854
- "POST", UPSTREAM_URL,
855
- json=payload, headers=headers,
856
- )
857
  resp = await client.send(upstream_req, stream=True)
858
 
859
  if resp.status_code != 200:
860
- raw_err = (
861
- await resp.aread()
862
- ).decode(errors="ignore")
863
- logger.warning(
864
- "[%s] fail %d: %s",
865
- req_id, resp.status_code, raw_err[:200],
866
- )
867
  last_error = raw_err
868
  await asyncio.sleep(RETRY_DELAY)
869
  continue
@@ -874,43 +644,22 @@ async def messages(
874
  headers=STREAM_HEADERS,
875
  )
876
 
877
- # ---------- NORMAL ----------
878
- resp = await client.post(
879
- UPSTREAM_URL, json=payload, headers=headers,
880
- )
881
-
882
  if resp.status_code != 200:
883
- logger.warning(
884
- "[%s] fail %d", req_id, resp.status_code
885
- )
886
  last_error = resp.text
887
  await asyncio.sleep(RETRY_DELAY)
888
  continue
889
 
890
- if not resp.text.strip():
891
- return JSONResponse(
892
- status_code=502,
893
- content={"error": "Empty upstream response"},
894
- )
895
-
896
- try:
897
- parsed = resp.json()
898
- except Exception:
899
- return JSONResponse(
900
- status_code=502,
901
- content={"error": "Invalid upstream JSON"},
902
- )
903
-
904
- return JSONResponse(
905
- content=convert_response(parsed, requested_model)
906
- )
907
 
908
  except Exception as err:
909
  last_error = str(err)
910
- logger.exception("[%s] exception", req_id)
911
  await asyncio.sleep(RETRY_DELAY)
912
 
913
  return JSONResponse(
914
  status_code=502,
915
- content={"error": "Upstream failed", "details": last_error},
916
- )
 
10
  from fastapi.responses import JSONResponse, StreamingResponse
11
 
12
  # =========================================================
13
+ # LOGGING CONFIGURATION
14
  # =========================================================
 
15
  logging.basicConfig(
16
  level=logging.INFO,
17
  format="%(asctime)s [%(levelname)s] %(message)s"
 
19
  logger = logging.getLogger("gateway")
20
 
21
  # =========================================================
22
+ # CONFIGURATION & ENVIRONMENT VARIABLES
23
  # =========================================================
 
24
  OPENCODE_API_KEY = os.getenv("OPENCODE_API_KEY", "").strip()
25
  UPSTREAM_URL = "https://opencode.ai/zen/v1/chat/completions"
26
  MAX_RETRIES = 5
27
+ RETRY_DELAY = 2
28
 
29
  UPSTREAM_HEADERS = {
30
  "Content-Type": "application/json",
 
34
  "User-Agent": (
35
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
36
  "AppleWebKit/537.36 (KHTML, like Gecko) "
37
+ "Chrome/125.0.0.0 Safari/537.36"
38
  ),
39
  }
40
 
 
45
  }
46
 
47
  # =========================================================
48
+ # DYNAMIC MODEL POOL (To avoid 401 & 429 errors)
49
  # =========================================================
50
+ # Removed unstable models that caused 401 Unauthorized in logs
 
51
  _FREE_POOL = [
52
  "deepseek-v4-flash-free",
53
+ "nemotron-3-super-free"
 
 
 
54
  ]
55
  _pool_idx = 0
56
 
 
57
  def _pick_free_model():
58
+ """Fallback mechanism using round-robin for stable models."""
59
  global _pool_idx
60
  model = _FREE_POOL[_pool_idx % len(_FREE_POOL)]
61
  _pool_idx += 1
62
  return model
63
 
 
64
  MODEL_MAP = {
65
+ "claude-sonnet-4-6": None, # Dynamic resolution
 
66
  "claude-sonnet-4-5": None,
67
  "claude-haiku-4-6": None,
68
  "claude-haiku-4-5": None,
69
  "claude-haiku-4-5-20251001": None,
70
  "claude-opus-4-6": "nemotron-3-super-free",
 
71
  "deepseek-v4-flash-free": "deepseek-v4-flash-free",
72
  "nemotron-3-super-free": "nemotron-3-super-free",
 
 
 
73
  }
74
 
75
  DEFAULT_MODEL = "deepseek-v4-flash-free"
 
77
  _MODELS_CACHE = {
78
  "object": "list",
79
  "data": [
80
+ {"id": m, "object": "model", "created": 1710000000, "owned_by": "gateway"}
 
 
 
 
 
81
  for m in MODEL_MAP
82
  ],
83
  }
84
 
85
  # =========================================================
86
+ # CONNECTOR PROMPT BUILDER
87
  # =========================================================
88
+ def _build_connector_prompt(tools):
89
+ """Injects tool metadata into the system prompt to guide the model."""
90
+ if not tools:
91
+ return ""
92
+
93
+ prompt = "💡 SYSTEM CONNECTOR: You have access to real-time tools and local environment execution.\n"
94
+ prompt += "Available Tools:\n"
95
+ for t in tools:
96
+ name = t.get("name", "unknown")
97
+ desc = t.get("description", "No description")
98
+ prompt += f"- `{name}`: {desc}\n"
99
+ prompt += "When a tool is needed, respond with the appropriate tool call sequence seamlessly."
100
+ return prompt
101
 
102
  # =========================================================
103
  # THINK-TAG PARSER
 
 
 
104
  # =========================================================
 
105
  class _ThinkParser:
 
106
  OPEN = "<think>"
107
  CLOSE = "</think>"
108
 
 
111
  self._in = False
112
 
113
  def feed(self, text):
 
114
  self._buf += text
115
  while self._buf:
116
  prev = len(self._buf)
 
134
  o = self._buf.find(self.OPEN)
135
  c = self._buf.find(self.CLOSE)
136
 
 
137
  if c != -1 and (o == -1 or c < o):
138
  pre = self._buf[:c]
139
  self._buf = self._buf[c + len(self.CLOSE):]
 
143
  br = self._buf.rfind("<")
144
  if br != -1:
145
  tail = self._buf[br:]
146
+ if len(tail) < len(self.OPEN) and self.OPEN.startswith(tail):
 
 
 
147
  emit = self._buf[:br]
148
  self._buf = self._buf[br:]
149
  return (False, emit) if emit else None
 
162
  br = self._buf.rfind("<")
163
  if br != -1:
164
  tail = self._buf[br:]
165
+ if len(tail) < len(self.CLOSE) and self.CLOSE.startswith(tail):
 
 
 
166
  emit = self._buf[:br]
167
  self._buf = self._buf[br:]
168
  return (True, emit) if emit else None
 
176
  return (True, thinking) if thinking else None
177
 
178
  # =========================================================
179
+ # JSON SCHEMA REPAIR
180
  # =========================================================
181
+ _VALID_TYPES = {"object", "array", "string", "number", "boolean", "integer"}
 
 
 
 
 
182
 
183
  def _fix_schema(schema):
184
  if not isinstance(schema, dict):
 
190
  return schema
191
 
192
  # =========================================================
193
+ # REASONING & PROTOCOL NORMALIZE
 
 
194
  # =========================================================
 
195
  def _extract_reasoning(msg):
196
  rc = msg.get("reasoning_content")
197
  if isinstance(rc, str) and rc:
 
201
  parts = [
202
  item.get("thinking", "")
203
  for item in content
204
+ if isinstance(item, dict) and item.get("type") == "thinking"
 
205
  ]
206
  joined = "".join(parts)
207
  if joined:
208
  return joined
209
  return None
210
 
 
 
 
 
211
  def _system_to_str(raw):
212
  if isinstance(raw, str):
213
  return raw or ""
 
218
  )
219
  return ""
220
 
 
 
 
 
221
  def normalize_messages(messages):
 
222
  output = []
 
223
  for msg in messages:
 
224
  role = msg.get("role", "user")
225
  content = msg.get("content", "")
226
 
 
234
  continue
235
 
236
  if isinstance(content, list):
 
237
  text_parts = []
238
  tool_calls = []
239
  tool_results = []
240
 
241
  for item in content:
 
242
  if not isinstance(item, dict):
243
  text_parts.append(str(item))
244
  continue
245
 
246
  t = item.get("type", "")
 
247
  if t == "text":
248
  txt = item.get("text", "")
249
  if txt:
250
  text_parts.append(txt)
 
 
 
 
251
  elif t == "tool_use":
252
  tool_calls.append({
253
+ "id": item.get("id", f"call_{uuid.uuid4().hex[:8]}"),
 
 
254
  "type": "function",
255
  "function": {
256
  "name": item.get("name", ""),
257
+ "arguments": json.dumps(item.get("input", {})),
 
 
258
  },
259
  })
 
260
  elif t == "tool_result":
261
  rc_c = item.get("content", "")
262
  if isinstance(rc_c, list):
263
  rc_c = "\n".join(
264
+ c.get("text", "") if isinstance(c, dict) else str(c)
 
265
  for c in rc_c
266
  )
267
  tool_results.append({
 
274
  am = {
275
  "role": "assistant",
276
  "tool_calls": tool_calls,
277
+ "content": "\n".join(text_parts) if text_parts else None,
 
 
278
  }
279
  rc = _extract_reasoning(msg)
280
  if rc:
281
  am["reasoning_content"] = rc
282
  output.append(am)
 
283
  elif tool_results:
284
  output.extend(tool_results)
 
285
  else:
286
+ entry = {"role": role, "content": "\n".join(text_parts)}
 
 
 
287
  if role == "assistant":
288
  rc = _extract_reasoning(msg)
289
  if rc:
290
  entry["reasoning_content"] = rc
291
  output.append(entry)
 
292
  continue
293
 
294
  output.append({"role": role, "content": str(content)})
 
295
  return output
296
 
297
  # =========================================================
298
+ # ANTHROPIC TO OPENAI TOOL BRIDGE
299
  # =========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  def convert_tools(tools):
301
+ """Converts Anthropic style tool specifications into OpenAI schema format."""
302
  result = []
303
  for t in tools:
304
+ if not isinstance(t, dict):
305
+ continue
306
  result.append({
307
  "type": "function",
308
  "function": {
 
313
  })
314
  return result
315
 
 
316
  def map_tool_choice(raw, has_tools):
317
  if not raw or not has_tools:
318
  return None
 
321
  if t == "any":
322
  return "required"
323
  if t == "tool":
324
+ return {"type": "function", "function": {"name": raw.get("name", "")}}
 
 
 
325
  return t
326
  return raw
327
 
328
  # =========================================================
329
+ # RESPONSE CONVERTERS
330
  # =========================================================
 
331
  _FINISH_MAP = {"tool_calls": "tool_use", "length": "max_tokens"}
332
 
 
333
  def convert_response(parsed, model):
 
334
  blocks = []
335
  stop_reason = "end_turn"
 
336
  try:
337
  choices = parsed.get("choices") or []
338
  if choices:
339
  choice = choices[0]
340
  message = choice.get("message", {})
341
+ stop_reason = _FINISH_MAP.get(choice.get("finish_reason"), "end_turn")
 
 
342
 
343
  reasoning = message.get("reasoning_content", "")
344
  if reasoning:
 
361
  "name": fn.get("name", ""),
362
  "input": args,
363
  })
 
364
  except Exception as err:
365
+ logger.warning("convert_response chain error: %s", err)
366
 
367
  if not blocks:
368
  blocks.append({"type": "text", "text": ""})
 
383
  }
384
 
385
  # =========================================================
386
+ # STREAM CONVERTER (CLAUDE PROTOCOL SSE)
387
  # =========================================================
 
388
  _E_MSG_START = "event: message_start\ndata: "
389
  _E_BLOCK_START = "event: content_block_start\ndata: "
390
  _E_BLOCK_DELTA = "event: content_block_delta\ndata: "
 
392
  _E_MSG_DELTA = "event: message_delta\ndata: "
393
  _E_MSG_STOP = "event: message_stop\ndata: "
394
 
 
395
  async def stream_convert(response, model):
 
396
  msg_id = f"msg_{uuid.uuid4().hex}"
397
  out_tokens = 0
398
  stop_reason = "end_turn"
 
405
 
406
  parser = _ThinkParser()
407
 
408
+ yield (_E_MSG_START + json.dumps({
409
+ "type": "message_start",
410
+ "message": {
411
+ "id": msg_id, "type": "message", "role": "assistant", "model": model,
412
+ "content": [], "stop_reason": None, "stop_sequence": None,
413
+ "usage": {"input_tokens": 0, "output_tokens": 0},
414
+ },
415
+ }) + "\n\n")
416
+
 
 
 
 
 
 
417
  yield 'event: ping\ndata: {"type":"ping"}\n\n'
418
 
 
 
419
  def open_thinking():
420
  nonlocal thinking_idx, next_idx
421
  thinking_idx = next_idx
422
  next_idx += 1
423
+ return (_E_BLOCK_START + json.dumps({
424
+ "type": "content_block_start", "index": thinking_idx,
425
+ "content_block": {"type": "thinking", "thinking": ""},
426
+ }) + "\n\n")
 
 
 
 
 
427
 
428
  def delta_thinking(text):
429
+ return (_E_BLOCK_DELTA + json.dumps({
430
+ "type": "content_block_delta", "index": thinking_idx,
431
+ "delta": {"type": "thinking_delta", "thinking": text},
432
+ }) + "\n\n")
 
 
 
 
 
433
 
434
  def open_text():
435
  nonlocal text_idx, next_idx
436
  text_idx = next_idx
437
  next_idx += 1
438
+ return (_E_BLOCK_START + json.dumps({
439
+ "type": "content_block_start", "index": text_idx,
440
+ "content_block": {"type": "text", "text": ""},
441
+ }) + "\n\n")
 
 
 
 
 
442
 
443
  def delta_text(text):
444
+ return (_E_BLOCK_DELTA + json.dumps({
445
+ "type": "content_block_delta", "index": text_idx,
446
+ "delta": {"type": "text_delta", "text": text},
447
+ }) + "\n\n")
 
 
 
 
 
448
 
449
  try:
 
450
  async for raw in response.aiter_lines():
 
451
  if not raw or not raw.startswith("data:"):
452
  continue
 
453
  line = raw[5:].strip()
 
454
  if line == "[DONE]":
455
  break
 
456
  try:
457
  chunk = json.loads(line)
458
  except Exception:
459
  continue
460
 
461
  if chunk.get("usage"):
462
+ out_tokens = chunk["usage"].get("completion_tokens", out_tokens)
 
 
463
 
464
  choices = chunk.get("choices")
465
  if not choices:
 
474
  elif finish == "length":
475
  stop_reason = "max_tokens"
476
 
 
477
  rc = delta.get("reasoning_content")
478
  if rc:
479
  if thinking_idx is None:
 
481
  yield delta_thinking(rc)
482
  continue
483
 
 
484
  raw_text = delta.get("content") or ""
485
  if raw_text:
486
  for is_thinking, chunk_text in parser.feed(raw_text):
 
493
  yield open_text()
494
  yield delta_text(chunk_text)
495
 
 
496
  for tc in delta.get("tool_calls") or []:
497
  idx = tc.get("index", 0)
 
498
  if idx not in tool_state:
499
+ tool_state[idx] = {"id": tc.get("id", f"toolu_{uuid.uuid4().hex[:8]}"), "name": ""}
 
 
 
 
 
500
  blk = next_idx
501
  next_idx += 1
502
  tool_idx[idx] = blk
503
  fn_name = (tc.get("function") or {}).get("name", "")
504
  tool_state[idx]["name"] = fn_name
505
+ yield (_E_BLOCK_START + json.dumps({
506
+ "type": "content_block_start", "index": blk,
507
+ "content_block": {"type": "tool_use", "id": tool_state[idx]["id"], "name": fn_name, "input": {}},
508
+ }) + "\n\n")
 
 
 
 
 
 
 
 
 
 
509
 
510
  args = (tc.get("function") or {}).get("arguments", "")
511
  if args:
512
+ yield (_E_BLOCK_DELTA + json.dumps({
513
+ "type": "content_block_delta", "index": tool_idx[idx],
514
+ "delta": {"type": "input_json_delta", "partial_json": args},
515
+ }) + "\n\n")
 
 
 
 
 
 
 
 
516
 
517
  except Exception as err:
518
+ logger.warning("Stream error mapping context: %s", err)
519
 
 
520
  for is_thinking, chunk_text in (parser.flush() or []):
521
  if is_thinking:
522
+ if thinking_idx is None: yield open_thinking()
 
523
  yield delta_thinking(chunk_text)
524
  else:
525
+ if text_idx is None: yield open_text()
 
526
  yield delta_text(chunk_text)
527
 
 
528
  if thinking_idx is not None:
529
+ yield (_E_BLOCK_STOP + json.dumps({"type": "content_block_stop", "index": thinking_idx}) + "\n\n")
 
 
 
 
 
 
 
 
530
  if text_idx is not None:
531
+ yield (_E_BLOCK_STOP + json.dumps({"type": "content_block_stop", "index": text_idx}) + "\n\n")
 
 
 
 
 
 
 
 
532
  for _, blk in tool_idx.items():
533
+ yield (_E_BLOCK_STOP + json.dumps({"type": "content_block_stop", "index": blk}) + "\n\n")
 
 
 
 
 
 
 
534
 
535
+ yield (_E_MSG_DELTA + json.dumps({
536
+ "type": "message_delta",
537
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
538
+ "usage": {"output_tokens": out_tokens},
539
+ }) + "\n\n")
 
 
 
 
 
 
 
540
 
541
+ yield (_E_MSG_STOP + json.dumps({"type": "message_stop"}) + "\n\n")
 
 
 
 
542
 
543
  # =========================================================
544
+ # FASTAPI LIFECYCLE
545
  # =========================================================
 
546
  @asynccontextmanager
547
  async def lifespan(app: FastAPI):
548
  app.state.client = httpx.AsyncClient(
549
  http2=False,
550
  timeout=httpx.Timeout(connect=20, read=300, write=60, pool=60),
551
+ limits=httpx.Limits(max_connections=150, max_keepalive_connections=30),
 
 
 
552
  follow_redirects=True,
553
  )
554
+ logger.info("Gateway initialized successfully.")
555
  yield
556
  await app.state.client.aclose()
557
 
 
558
  app = FastAPI(lifespan=lifespan)
559
 
 
560
  @app.get("/")
 
 
 
 
 
561
  @app.get("/health")
562
  async def health():
563
+ return {"status": "healthy", "service": "OpenCode Claude Bridge"}
 
564
 
565
  @app.get("/v1/models")
566
  async def get_models():
567
  return _MODELS_CACHE
568
 
569
  # =========================================================
570
+ # MAIN ROUTER ENDPOINT
571
  # =========================================================
 
572
  @app.post("/v1/messages")
573
  @app.post("/v1/chat/completions")
574
+ async def messages(request: Request, authorization: str = Header(default=None)):
 
 
 
575
  req_id = uuid.uuid4().hex[:8]
576
 
577
  if not OPENCODE_API_KEY:
578
+ return JSONResponse(status_code=401, content={"error": "Missing OPENCODE_API_KEY"})
 
 
 
579
 
580
  try:
581
  body = await request.json()
582
  except Exception:
583
+ return JSONResponse(status_code=400, content={"error": "Invalid JSON payload"})
 
 
 
584
 
585
  requested_model = body.get("model", "claude-sonnet-4-6")
586
  _direct = MODEL_MAP.get(requested_model, DEFAULT_MODEL)
587
  mapped_model = _pick_free_model() if _direct is None else _direct
588
  stream = bool(body.get("stream", False))
589
 
590
+ # Handle Tools and Connector Protocol Bridge
591
  raw_tools = body.get("tools", [])
592
+ openai_tools = convert_tools(raw_tools)
593
+ tool_choice = map_tool_choice(body.get("tool_choice"), bool(openai_tools))
 
 
 
594
 
 
595
  oai_messages = normalize_messages(body.get("messages", []))
596
 
597
+ # Incorporate tools context natively into instructions if model behaves as connector
598
+ sys_str = _system_to_str(body.get("system", ""))
599
+ connector_str = _build_connector_prompt(raw_tools)
600
+
601
+ parts = [p for p in [connector_str, sys_str] if p]
602
+ full_system = "\n\n---\n\n".join(parts)
603
+
604
+ if full_system:
605
+ oai_messages.insert(0, {"role": "system", "content": full_system})
606
 
 
607
  payload = {
608
  "model": mapped_model,
609
  "messages": oai_messages,
610
+ "temperature": body.get("temperature", 0.5),
611
+ "max_tokens": min(body.get("max_tokens", 4096), 8192),
612
  "stream": stream,
613
  }
614
+
615
  if openai_tools:
616
  payload["tools"] = openai_tools
617
  if tool_choice is not None:
 
619
 
620
  headers = {**UPSTREAM_HEADERS, "x-api-key": OPENCODE_API_KEY}
621
 
622
+ logger.info("[%s] Routing %s -> %s | Stream=%s | Tools Passed=%d",
623
+ req_id, requested_model, mapped_model, stream, len(openai_tools))
 
 
 
624
 
625
+ client = request.app.state.client
626
  last_error = None
627
 
628
  for attempt in range(1, MAX_RETRIES + 1):
 
629
  try:
 
 
630
  if stream:
631
+ upstream_req = client.build_request("POST", UPSTREAM_URL, json=payload, headers=headers)
 
 
 
 
632
  resp = await client.send(upstream_req, stream=True)
633
 
634
  if resp.status_code != 200:
635
+ raw_err = (await resp.aread()).decode(errors="ignore")
636
+ logger.warning("[%s] Upstream status %d: %s (Attempt %d)", req_id, resp.status_code, raw_err[:150], attempt)
 
 
 
 
 
637
  last_error = raw_err
638
  await asyncio.sleep(RETRY_DELAY)
639
  continue
 
644
  headers=STREAM_HEADERS,
645
  )
646
 
647
+ # Non-stream configuration
648
+ resp = await client.post(UPSTREAM_URL, json=payload, headers=headers)
 
 
 
649
  if resp.status_code != 200:
650
+ logger.warning("[%s] Upstream failed with status %d (Attempt %d)", req_id, resp.status_code, attempt)
 
 
651
  last_error = resp.text
652
  await asyncio.sleep(RETRY_DELAY)
653
  continue
654
 
655
+ return JSONResponse(content=convert_response(resp.json(), requested_model))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
 
657
  except Exception as err:
658
  last_error = str(err)
659
+ logger.warning("[%s] Connection error: %s (Attempt %d)", req_id, last_error, attempt)
660
  await asyncio.sleep(RETRY_DELAY)
661
 
662
  return JSONResponse(
663
  status_code=502,
664
+ content={"error": "All retry paths to OpenCode upstream failed", "details": last_error[:200]},
665
+ )