bahi-bh commited on
Commit
57ab2ef
·
verified ·
1 Parent(s): baf6b9d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1174 -684
app.py CHANGED
@@ -1,11 +1,18 @@
 
 
 
 
 
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
 
@@ -17,22 +24,32 @@ 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) "
@@ -40,46 +57,25 @@ UPSTREAM_HEADERS = {
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"
@@ -88,174 +84,113 @@ _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):
@@ -264,98 +199,202 @@ def normalize_messages(messages):
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
 
@@ -363,487 +402,746 @@ def normalize_messages(messages):
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
@@ -853,142 +1151,334 @@ async def get_models():
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
+ # =========================================================
2
+ # OpenCode Gateway
3
+ # Anthropic API compatibility layer for OpenCode / DeepSeek
4
+ # =========================================================
5
+
6
  import os
7
  import json
8
  import uuid
9
  import asyncio
10
  import logging
11
+
12
  from contextlib import asynccontextmanager
13
 
14
  import httpx
15
+
16
  from fastapi import FastAPI, Request, Header
17
  from fastapi.responses import JSONResponse, StreamingResponse
18
 
 
24
  level=logging.INFO,
25
  format="%(asctime)s [%(levelname)s] %(message)s"
26
  )
27
+
28
+ logger = logging.getLogger("OpenCodeGateway")
29
 
30
  # =========================================================
31
  # CONFIG
32
  # =========================================================
33
 
34
+ OPENCODE_API_KEY = os.getenv(
35
+ "OPENCODE_API_KEY",
36
+ ""
37
+ ).strip()
38
+
39
+ UPSTREAM_URL = "https://opencode.ai/zen/v1/chat/completions"
40
+
41
+ MAX_RETRIES = 2
42
+ RETRY_DELAY = 1
43
+
44
+ # =========================================================
45
+ # HEADERS
46
+ # =========================================================
47
 
48
  UPSTREAM_HEADERS = {
49
  "Content-Type": "application/json",
50
+ "Accept": "application/json",
51
+ "Origin": "https://opencode.ai",
52
+ "Referer": "https://opencode.ai/",
53
  "User-Agent": (
54
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
55
  "AppleWebKit/537.36 (KHTML, like Gecko) "
 
57
  ),
58
  }
59
 
60
+ STREAM_RESPONSE_HEADERS = {
61
+ "Cache-Control": "no-cache",
62
+ "Connection": "keep-alive",
63
  "X-Accel-Buffering": "no",
64
  }
65
 
66
  # =========================================================
67
+ # MODELS
68
  # =========================================================
69
 
70
+ MODEL_MAP = {
71
+ "claude-sonnet-4-6": "deepseek-v4-flash-free",
72
+ "claude-sonnet-4-5": "deepseek-v4-flash-free",
 
 
 
 
 
 
 
 
 
 
 
73
 
74
+ "claude-haiku-4-6": "minimax-m2.5-free",
75
+ "claude-haiku-4-5": "qwen3.6-plus-free",
76
+ "claude-haiku-4-5-20251001": "deepseek-v4-flash-free",
77
 
78
+ "claude-opus-4-6": "nemotron-3-super-free",
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
 
81
  DEFAULT_MODEL = "deepseek-v4-flash-free"
 
84
  "object": "list",
85
  "data": [
86
  {
87
+ "id": model,
88
  "object": "model",
89
  "created": 1710000000,
90
  "owned_by": "gateway"
91
  }
92
+ for model in MODEL_MAP
93
  ],
94
  }
95
 
96
  # =========================================================
97
+ # FASTAPI
98
  # =========================================================
99
 
100
+ @asynccontextmanager
101
+ async def lifespan(app: FastAPI):
102
+
103
+ app.state.client = httpx.AsyncClient(
104
+ http2=False,
105
+
106
+ timeout=httpx.Timeout(
107
+ connect=20,
108
+ read=300,
109
+ write=60,
110
+ pool=60
111
+ ),
112
+
113
+ limits=httpx.Limits(
114
+ max_connections=100,
115
+ max_keepalive_connections=20
116
+ ),
117
+
118
+ follow_redirects=True,
119
+ )
120
+
121
+ logger.info("Gateway started.")
122
+
123
+ yield
124
+
125
+ await app.state.client.aclose()
126
+
127
+
128
+ app = FastAPI(lifespan=lifespan)
129
 
130
  # =========================================================
131
+ # ROOT
 
 
 
132
  # =========================================================
133
 
134
+ @app.get("/")
135
+ @app.head("/")
136
+ async def root():
137
 
138
+ return {
139
+ "status": "ok",
140
+ "service": "OpenCode Gateway"
141
+ }
142
 
 
 
 
143
 
144
+ @app.get("/health")
145
+ async def health():
 
 
 
 
 
 
 
 
146
 
147
+ return {
148
+ "status": "healthy"
149
+ }
150
+
151
+
152
+ @app.get("/v1/models")
153
+ async def get_models():
154
+
155
+ return _MODELS_CACHE
156
+
157
+ # =========================================================
158
+ # FIX TOOL SCHEMA
159
+ # =========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
+ _VALID_SCHEMA_TYPES = {
162
+ "object",
163
+ "array",
164
+ "string",
165
+ "number",
166
+ "boolean",
167
+ "integer"
168
+ }
169
 
170
  def _fix_schema(schema):
171
+
172
  if not isinstance(schema, dict):
173
+
174
+ return {
175
+ "type": "object",
176
+ "properties": {}
177
+ }
178
+
179
+ fixed = dict(schema)
180
+
181
+ if fixed.get("type") not in _VALID_SCHEMA_TYPES:
182
+
183
+ fixed["type"] = "object"
184
+
185
+ if fixed["type"] == "object":
186
+
187
+ fixed.setdefault("properties", {})
188
+
189
+ return fixed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  # =========================================================
192
  # NORMALIZE MESSAGES
193
+ # Anthropic -> OpenAI
194
  # =========================================================
195
 
196
  def normalize_messages(messages):
 
199
 
200
  for msg in messages:
201
 
202
+ role = msg.get("role", "user")
203
+
204
  content = msg.get("content", "")
205
 
206
+ # -------------------------------------------------
207
+ # STRING CONTENT
208
+ # -------------------------------------------------
209
+
210
  if isinstance(content, str):
211
+
212
+ clean_msg = {
213
+ "role": role,
214
+ "content": content
215
+ }
216
+
217
+ # pass reasoning_content through
218
+ if msg.get("reasoning_content"):
219
+
220
+ clean_msg["reasoning_content"] = (
221
+ msg["reasoning_content"]
222
+ )
223
+
224
+ output.append(clean_msg)
225
+
226
  continue
227
 
228
+ # -------------------------------------------------
229
+ # ARRAY CONTENT
230
+ # -------------------------------------------------
231
+
232
  if isinstance(content, list):
233
 
234
+ text_parts = []
235
+
236
+ tool_calls = []
237
+
238
  tool_results = []
239
 
240
  for item in content:
241
 
242
  if not isinstance(item, dict):
243
+
244
  text_parts.append(str(item))
245
+
246
  continue
247
 
248
+ item_type = item.get("type", "")
249
+
250
+ # -----------------------------------------
251
+ # TEXT
252
+ # -----------------------------------------
253
+
254
+ if item_type == "text":
255
 
 
256
  txt = item.get("text", "")
257
+
258
  if txt:
259
+
260
  text_parts.append(txt)
261
 
262
+ # -----------------------------------------
263
+ # TOOL USE
264
+ # -----------------------------------------
265
+
266
+ elif item_type == "tool_use":
267
 
 
268
  tool_calls.append({
269
+
270
+ "id": item.get(
271
+ "id",
272
+ f"call_{uuid.uuid4().hex[:8]}"
273
  ),
274
+
275
  "type": "function",
276
+
277
  "function": {
278
+
279
+ "name": item.get(
280
+ "name",
281
+ ""
282
+ ),
283
+
284
  "arguments": json.dumps(
285
  item.get("input", {})
286
  ),
287
  },
288
  })
289
 
290
+ # -----------------------------------------
291
+ # TOOL RESULT
292
+ # -----------------------------------------
293
+
294
+ elif item_type == "tool_result":
295
+
296
+ rc = item.get("content", "")
297
+
298
+ if isinstance(rc, list):
299
+
300
+ rc = "\n".join(
301
  c.get("text", "")
302
+ if isinstance(c, dict)
303
+ else str(c)
304
+ for c in rc
305
  )
306
+
307
  tool_results.append({
308
+
309
+ "role": "tool",
310
+
311
+ "tool_call_id": item.get(
312
+ "tool_use_id",
313
+ ""
314
+ ),
315
+
316
+ "content": str(rc),
317
  })
318
 
319
+ # ---------------------------------------------
320
+ # ASSISTANT WITH TOOLS
321
+ # ---------------------------------------------
322
+
323
  if tool_calls:
324
+
325
+ assistant_msg = {
326
+
327
+ "role": "assistant",
328
+
329
  "tool_calls": tool_calls,
330
+
331
  "content": (
332
+ "\n".join(text_parts)
333
+ if text_parts
334
+ else None
335
+ )
336
  }
337
+
338
+ # IMPORTANT:
339
+ # pass reasoning_content through
340
+
341
+ if msg.get("reasoning_content"):
342
+
343
+ assistant_msg["reasoning_content"] = (
344
+ msg["reasoning_content"]
345
+ )
346
+
347
+ output.append(assistant_msg)
348
+
349
+ # ---------------------------------------------
350
+ # TOOL RESULTS
351
+ # ---------------------------------------------
352
 
353
  elif tool_results:
354
+
355
  output.extend(tool_results)
356
 
357
+ # ---------------------------------------------
358
+ # NORMAL ARRAY MESSAGE
359
+ # ---------------------------------------------
360
+
361
  else:
362
+
363
+ normal_msg = {
364
+
365
+ "role": role,
366
+
367
+ "content": "\n".join(text_parts)
368
  }
369
+
370
+ if msg.get("reasoning_content"):
371
+
372
+ normal_msg["reasoning_content"] = (
373
+ msg["reasoning_content"]
374
+ )
375
+
376
+ output.append(normal_msg)
377
 
378
  continue
379
 
380
+ # -------------------------------------------------
381
+ # FALLBACK
382
+ # -------------------------------------------------
383
+
384
+ fallback_msg = {
385
+
386
+ "role": role,
387
+
388
+ "content": str(content)
389
+ }
390
+
391
+ if msg.get("reasoning_content"):
392
+
393
+ fallback_msg["reasoning_content"] = (
394
+ msg["reasoning_content"]
395
+ )
396
+
397
+ output.append(fallback_msg)
398
 
399
  return output
400
 
 
402
  # TOOLS
403
  # =========================================================
404
 
405
+ def convert_tools_to_openai(tools):
 
 
 
 
 
 
 
 
 
 
 
406
 
407
+ if not tools:
408
+ return []
409
 
 
410
  result = []
411
+
412
+ for tool in tools:
413
+
414
+ fixed_schema = _fix_schema(
415
+ tool.get("input_schema", {})
416
+ )
417
+
418
  result.append({
419
+
420
  "type": "function",
421
+
422
  "function": {
423
+
424
+ "name": tool.get(
425
+ "name",
426
+ ""
427
+ ),
428
+
429
+ "description": tool.get(
430
+ "description",
431
+ ""
432
+ ),
433
+
434
+ "parameters": fixed_schema,
435
  },
436
  })
437
+
438
  return result
439
 
440
+ # =========================================================
441
+ # TOOL CHOICE
442
+ # =========================================================
443
+
444
+ _TOOL_CHOICE_ANY = "required"
445
+
446
+ def map_tool_choice(
447
+ tool_choice_raw,
448
+ has_tools
449
+ ):
450
+
451
+ if not tool_choice_raw or not has_tools:
452
 
 
 
453
  return None
454
+
455
+ if isinstance(tool_choice_raw, dict):
456
+
457
+ tool_type = tool_choice_raw.get(
458
+ "type",
459
+ "auto"
460
+ )
461
+
462
+ if tool_type == "any":
463
+
464
+ return _TOOL_CHOICE_ANY
465
+
466
+ if tool_type == "tool":
467
+
468
  return {
469
+
470
  "type": "function",
471
+
472
+ "function": {
473
+ "name": tool_choice_raw.get(
474
+ "name",
475
+ ""
476
+ )
477
+ }
478
  }
479
+
480
+ return tool_type
481
+
482
+ return tool_choice_raw
483
 
484
  # =========================================================
485
+ # BUILD PAYLOAD
486
  # =========================================================
487
 
488
+ def build_payload(
489
+ body,
490
+ openai_messages,
491
+ openai_tools,
492
+ openai_tool_choice,
493
+ mapped_model,
494
+ stream
495
+ ):
496
 
497
+ payload = {
498
 
499
+ "model": mapped_model,
500
 
501
+ "messages": openai_messages,
 
502
 
503
+ "temperature": body.get(
504
+ "temperature",
505
+ 0.7
506
+ ),
 
 
 
 
507
 
508
+ "max_tokens": min(
509
+ body.get("max_tokens", 32000),
510
+ 64000
511
+ ),
512
 
513
+ "stream": stream,
514
+ }
 
515
 
516
+ if openai_tools:
 
 
 
 
 
 
 
 
 
 
 
 
517
 
518
+ payload["tools"] = openai_tools
 
519
 
520
+ if openai_tool_choice is not None:
 
521
 
522
+ payload["tool_choice"] = (
523
+ openai_tool_choice
524
+ )
525
+
526
+ return payload
 
 
 
 
 
 
 
 
 
527
 
528
  # =========================================================
529
+ # CONVERT NORMAL RESPONSE
530
+ # OpenAI -> Anthropic
531
  # =========================================================
532
 
533
+ _FINISH_MAP = {
534
+ "tool_calls": "tool_use",
535
+ "length": "max_tokens"
536
+ }
 
 
537
 
538
+ def convert_to_anthropic(
539
+ parsed,
540
+ requested_model
541
+ ):
542
 
543
+ content_blocks = []
544
 
 
 
545
  stop_reason = "end_turn"
546
 
547
+ reasoning_content = ""
 
 
 
 
548
 
549
+ try:
550
 
551
+ choices = parsed.get("choices")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
+ if choices:
 
 
 
 
 
 
 
 
 
554
 
555
+ choice = choices[0]
 
 
 
 
 
 
 
 
 
 
 
 
556
 
557
+ message = choice.get(
558
+ "message",
559
+ {}
560
+ )
 
 
 
 
 
 
561
 
562
+ stop_reason = _FINISH_MAP.get(
563
+ choice.get("finish_reason"),
564
+ "end_turn"
565
+ )
566
 
567
+ text = (
568
+ message.get("content")
569
+ or ""
570
+ )
571
 
572
+ reasoning_content = (
573
+ message.get("reasoning_content")
574
+ or message.get("reasoning")
575
+ or ""
576
+ )
577
 
578
+ if text:
579
 
580
+ content_blocks.append({
 
581
 
582
+ "type": "text",
 
 
 
583
 
584
+ "text": text
585
+ })
586
+
587
+ for tc in (
588
+ message.get("tool_calls")
589
+ or []
590
+ ):
591
+
592
+ fn = tc.get(
593
+ "function",
594
+ {}
595
  )
596
 
597
+ raw_args = fn.get(
598
+ "arguments",
599
+ "{}"
600
+ )
601
 
602
+ try:
 
 
603
 
604
+ parsed_args = json.loads(
605
+ raw_args
606
+ )
 
607
 
608
+ except Exception:
 
 
 
 
 
 
609
 
610
+ parsed_args = {
611
+ "raw": raw_args
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
 
614
+ content_blocks.append({
 
 
 
 
 
 
 
 
 
 
 
 
 
615
 
616
+ "type": "tool_use",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
 
618
+ "id": tc.get(
619
+ "id",
620
+ f"toolu_{uuid.uuid4().hex[:8]}"
621
+ ),
 
 
 
 
 
622
 
623
+ "name": fn.get(
624
+ "name",
625
+ ""
626
+ ),
627
+
628
+ "input": parsed_args,
629
+ })
630
+
631
+ except Exception as error:
632
+
633
+ logger.warning(
634
+ "convert error: %s",
635
+ error
636
  )
637
 
638
+ if not content_blocks:
639
+
640
+ content_blocks.append({
641
+
642
+ "type": "text",
643
+
644
+ "text": ""
 
 
645
  })
 
 
646
 
647
+ usage = parsed.get(
648
+ "usage",
649
+ {}
 
650
  )
651
 
652
+ response = {
 
 
 
 
 
653
 
654
+ "id": f"msg_{uuid.uuid4().hex}",
 
 
655
 
656
+ "type": "message",
657
 
658
+ "role": "assistant",
659
 
660
+ "model": requested_model,
 
 
 
 
 
 
 
 
 
 
661
 
662
+ "content": content_blocks,
663
+
664
+ "stop_reason": stop_reason,
665
+
666
+ "stop_sequence": None,
667
+
668
+ "usage": {
669
+
670
+ "input_tokens": usage.get(
671
+ "prompt_tokens",
672
+ 0
673
+ ),
674
+
675
+ "output_tokens": usage.get(
676
+ "completion_tokens",
677
+ 0
678
+ ),
679
+ },
680
  }
681
+
682
+ # IMPORTANT:
683
+ # preserve reasoning_content
684
+
685
+ if reasoning_content:
686
+
687
+ response["reasoning_content"] = (
688
+ reasoning_content
689
  )
690
 
691
+ return response
692
+
693
+ # =========================================================
694
+ # STREAM CONSTANTS
695
+ # =========================================================
696
+
697
+ _STREAM_MSG_START = "event: message_start\ndata: "
698
+ _STREAM_BLOCK_START = "event: content_block_start\ndata: "
699
+ _STREAM_BLOCK_DELTA = "event: content_block_delta\ndata: "
700
+ _STREAM_BLOCK_STOP = "event: content_block_stop\ndata: "
701
+ _STREAM_MSG_DELTA = "event: message_delta\ndata: "
702
+ _STREAM_MSG_STOP = "event: message_stop\ndata: "
703
+
704
+ # =========================================================
705
+ # STREAM CONVERSION
706
+ # OpenAI SSE -> Anthropic SSE
707
+ # =========================================================
708
+
709
+ async def convert_stream_to_anthropic(
710
+ response,
711
+ requested_model
712
+ ):
713
+
714
+ msg_id = f"msg_{uuid.uuid4().hex}"
715
+
716
+ input_tokens = 0
717
+ output_tokens = 0
718
+
719
+ reasoning_parts = []
720
+
721
+ start_payload = {
722
+
723
+ "type": "message_start",
724
+
725
+ "message": {
726
+
727
+ "id": msg_id,
728
+
729
+ "type": "message",
730
+
731
+ "role": "assistant",
732
+
733
+ "model": requested_model,
734
+
735
+ "content": [],
736
+
737
+ "reasoning_content": "",
738
+
739
+ "stop_reason": None,
740
+
741
+ "stop_sequence": None,
742
+
743
+ "usage": {
744
+
745
+ "input_tokens": 0,
746
+
747
+ "output_tokens": 0,
748
+ },
749
+ },
750
  }
751
+
752
+ yield (
753
+ f"{_STREAM_MSG_START}"
754
+ f"{json.dumps(start_payload)}"
755
+ "\n\n"
756
+ )
757
+
758
+ yield (
759
+ 'event: ping\ndata: {"type":"ping"}\n\n'
760
+ )
761
+
762
+ tool_state = {}
763
+
764
+ tool_block_idx = {}
765
+
766
+ text_block_idx = None
767
+
768
+ next_idx = 0
769
+
770
+ stop_reason = "end_turn"
771
+
772
+ try:
773
+
774
+ async for raw in response.aiter_lines():
775
+
776
+ if (
777
+ not raw
778
+ or not raw.startswith("data:")
779
+ ):
780
+ continue
781
+
782
+ line = raw[5:].strip()
783
+
784
+ if line == "[DONE]":
785
+ break
786
+
787
+ try:
788
+
789
+ chunk = json.loads(line)
790
+
791
+ except Exception:
792
+
793
+ continue
794
+
795
+ # -------------------------------------------------
796
+ # USAGE
797
+ # -------------------------------------------------
798
+
799
+ if chunk.get("usage"):
800
+
801
+ usage = chunk["usage"]
802
+
803
+ input_tokens = usage.get(
804
+ "prompt_tokens",
805
+ input_tokens
806
+ )
807
+
808
+ output_tokens = usage.get(
809
+ "completion_tokens",
810
+ output_tokens
811
+ )
812
+
813
+ choices = chunk.get("choices")
814
+
815
+ if not choices:
816
+ continue
817
+
818
+ choice = choices[0]
819
+
820
+ delta = choice.get(
821
+ "delta",
822
+ {}
823
+ )
824
+
825
+ finish_reason = choice.get(
826
+ "finish_reason"
827
+ )
828
+
829
+ # -------------------------------------------------
830
+ # REASONING DELTA
831
+ # -------------------------------------------------
832
+
833
+ reasoning_delta = (
834
+ delta.get("reasoning_content")
835
+ or delta.get("reasoning")
836
+ or ""
837
+ )
838
+
839
+ if reasoning_delta:
840
+
841
+ reasoning_parts.append(
842
+ reasoning_delta
843
+ )
844
+
845
+ # -------------------------------------------------
846
+ # STOP REASON
847
+ # -------------------------------------------------
848
+
849
+ if finish_reason == "tool_calls":
850
+
851
+ stop_reason = "tool_use"
852
+
853
+ elif finish_reason == "length":
854
+
855
+ stop_reason = "max_tokens"
856
+
857
+ # -------------------------------------------------
858
+ # TEXT DELTA
859
+ # -------------------------------------------------
860
+
861
+ text_delta = delta.get(
862
+ "content"
863
+ )
864
+
865
+ if text_delta:
866
+
867
+ if text_block_idx is None:
868
+
869
+ text_block_idx = next_idx
870
+
871
+ next_idx += 1
872
+
873
+ block_payload = {
874
+
875
+ "type": "content_block_start",
876
+
877
+ "index": text_block_idx,
878
+
879
+ "content_block": {
880
+
881
+ "type": "text",
882
+
883
+ "text": ""
884
+ }
885
+ }
886
+
887
+ yield (
888
+ f"{_STREAM_BLOCK_START}"
889
+ f"{json.dumps(block_payload)}"
890
+ "\n\n"
891
+ )
892
+
893
+ delta_payload = {
894
+
895
+ "type": "content_block_delta",
896
+
897
+ "index": text_block_idx,
898
+
899
+ "delta": {
900
+
901
+ "type": "text_delta",
902
+
903
+ "text": text_delta
904
+ }
905
+ }
906
+
907
+ yield (
908
+ f"{_STREAM_BLOCK_DELTA}"
909
+ f"{json.dumps(delta_payload)}"
910
+ "\n\n"
911
+ )
912
+
913
+ # -------------------------------------------------
914
+ # TOOL CALLS
915
+ # -------------------------------------------------
916
+
917
+ tool_call_deltas = delta.get(
918
+ "tool_calls"
919
+ )
920
+
921
+ if tool_call_deltas:
922
+
923
+ for tc_delta in tool_call_deltas:
924
+
925
+ idx = tc_delta.get(
926
+ "index",
927
+ 0
928
+ )
929
+
930
+ if idx not in tool_state:
931
+
932
+ tool_state[idx] = {
933
+
934
+ "id": tc_delta.get(
935
+ "id",
936
+ f"toolu_{uuid.uuid4().hex[:8]}"
937
+ ),
938
+
939
+ "name": "",
940
+
941
+ "args": "",
942
+ }
943
+
944
+ block_index = next_idx
945
+
946
+ next_idx += 1
947
+
948
+ tool_block_idx[idx] = (
949
+ block_index
950
+ )
951
+
952
+ fn = tc_delta.get(
953
+ "function",
954
+ {}
955
+ )
956
+
957
+ tool_state[idx]["name"] = (
958
+ fn.get("name", "")
959
+ )
960
+
961
+ block_payload = {
962
+
963
+ "type": "content_block_start",
964
+
965
+ "index": block_index,
966
+
967
+ "content_block": {
968
+
969
+ "type": "tool_use",
970
+
971
+ "id": tool_state[idx]["id"],
972
+
973
+ "name": tool_state[idx]["name"],
974
+
975
+ "input": {}
976
+ }
977
+ }
978
+
979
+ yield (
980
+ f"{_STREAM_BLOCK_START}"
981
+ f"{json.dumps(block_payload)}"
982
+ "\n\n"
983
+ )
984
+
985
+ fn = tc_delta.get(
986
+ "function",
987
+ {}
988
+ )
989
+
990
+ if fn.get("name"):
991
+
992
+ tool_state[idx]["name"] += (
993
+ fn["name"]
994
+ )
995
+
996
+ args = fn.get(
997
+ "arguments",
998
+ ""
999
+ )
1000
+
1001
+ if args:
1002
+
1003
+ tool_state[idx]["args"] += (
1004
+ args
1005
+ )
1006
+
1007
+ block_index = (
1008
+ tool_block_idx[idx]
1009
+ )
1010
+
1011
+ delta_payload = {
1012
+
1013
+ "type": "content_block_delta",
1014
+
1015
+ "index": block_index,
1016
+
1017
+ "delta": {
1018
+
1019
+ "type": "input_json_delta",
1020
+
1021
+ "partial_json": args
1022
+ }
1023
+ }
1024
+
1025
+ yield (
1026
+ f"{_STREAM_BLOCK_DELTA}"
1027
+ f"{json.dumps(delta_payload)}"
1028
+ "\n\n"
1029
+ )
1030
+
1031
+ except Exception as error:
1032
+
1033
+ logger.warning(
1034
+ "stream error: %s",
1035
+ error
1036
  )
1037
 
1038
+ # =====================================================
1039
+ # CLOSE TEXT BLOCK
1040
+ # =====================================================
1041
+
1042
+ if text_block_idx is not None:
1043
+
1044
+ close_payload = {
1045
+
1046
+ "type": "content_block_stop",
1047
+
1048
+ "index": text_block_idx
1049
+ }
1050
+
1051
+ yield (
1052
+ f"{_STREAM_BLOCK_STOP}"
1053
+ f"{json.dumps(close_payload)}"
1054
+ "\n\n"
1055
  )
1056
 
1057
+ # =====================================================
1058
+ # CLOSE TOOL BLOCKS
1059
+ # =====================================================
1060
 
1061
+ for _, block_index in tool_block_idx.items():
 
 
 
 
 
 
 
 
 
1062
 
1063
+ close_payload = {
1064
 
1065
+ "type": "content_block_stop",
1066
 
1067
+ "index": block_index
1068
+ }
 
1069
 
1070
+ yield (
1071
+ f"{_STREAM_BLOCK_STOP}"
1072
+ f"{json.dumps(close_payload)}"
1073
+ "\n\n"
1074
+ )
1075
+
1076
+ # =====================================================
1077
+ # FINAL REASONING DELTA
1078
+ # =====================================================
1079
+
1080
+ full_reasoning = "".join(
1081
+ reasoning_parts
1082
  )
 
 
 
1083
 
1084
+ if full_reasoning:
1085
 
1086
+ reasoning_payload = {
1087
 
1088
+ "type": "message_delta",
1089
 
1090
+ "delta": {
 
 
 
1091
 
1092
+ "reasoning_content": (
1093
+ full_reasoning
1094
+ )
1095
+ }
1096
+ }
1097
 
1098
+ yield (
1099
+ f"{_STREAM_MSG_DELTA}"
1100
+ f"{json.dumps(reasoning_payload)}"
1101
+ "\n\n"
1102
+ )
1103
 
1104
+ # =====================================================
1105
+ # FINAL MESSAGE DELTA
1106
+ # =====================================================
1107
 
1108
+ delta_payload = {
1109
+
1110
+ "type": "message_delta",
1111
+
1112
+ "delta": {
1113
+
1114
+ "stop_reason": stop_reason,
1115
+
1116
+ "stop_sequence": None
1117
+ },
1118
+
1119
+ "usage": {
1120
+
1121
+ "output_tokens": output_tokens
1122
+ }
1123
+ }
1124
+
1125
+ yield (
1126
+ f"{_STREAM_MSG_DELTA}"
1127
+ f"{json.dumps(delta_payload)}"
1128
+ "\n\n"
1129
+ )
1130
+
1131
+ # =====================================================
1132
+ # STOP
1133
+ # =====================================================
1134
+
1135
+ stop_payload = {
1136
+
1137
+ "type": "message_stop"
1138
+ }
1139
+
1140
+ yield (
1141
+ f"{_STREAM_MSG_STOP}"
1142
+ f"{json.dumps(stop_payload)}"
1143
+ "\n\n"
1144
+ )
1145
 
1146
  # =========================================================
1147
  # MAIN ENDPOINT
 
1151
  @app.post("/v1/chat/completions")
1152
  async def messages(
1153
  request: Request,
1154
+ authorization: str = Header(default=None)
1155
  ):
1156
+
1157
+ request_id = uuid.uuid4().hex[:8]
1158
+
1159
+ # =====================================================
1160
+ # API KEY
1161
+ # =====================================================
1162
 
1163
  if not OPENCODE_API_KEY:
1164
+
1165
  return JSONResponse(
1166
+
1167
  status_code=401,
1168
+
1169
+ content={
1170
+ "error": (
1171
+ "Missing OPENCODE_API_KEY"
1172
+ )
1173
+ }
1174
  )
1175
 
1176
+ # =====================================================
1177
+ # PARSE BODY
1178
+ # =====================================================
1179
+
1180
  try:
1181
+
1182
  body = await request.json()
1183
+
1184
  except Exception:
1185
+
1186
  return JSONResponse(
1187
+
1188
  status_code=400,
1189
+
1190
+ content={
1191
+ "error": "Invalid JSON"
1192
+ }
1193
  )
1194
 
1195
+ # =====================================================
1196
+ # MODEL
1197
+ # =====================================================
1198
+
1199
+ requested_model = body.get(
1200
+ "model",
1201
+ "claude-sonnet-4-6"
 
 
 
 
1202
  )
1203
 
1204
+ mapped_model = MODEL_MAP.get(
1205
+ requested_model,
1206
+ DEFAULT_MODEL
1207
+ )
1208
 
1209
+ stream = bool(
1210
+ body.get("stream", False)
1211
+ )
1212
 
1213
+ # =====================================================
1214
+ # TOOLS
1215
+ # =====================================================
1216
 
1217
+ anthropic_tools = body.get(
1218
+ "tools",
1219
+ []
1220
+ )
1221
 
1222
+ openai_tools = convert_tools_to_openai(
1223
+ anthropic_tools
1224
+ )
 
 
 
 
 
 
 
 
 
1225
 
1226
+ openai_tool_choice = map_tool_choice(
1227
+ body.get("tool_choice"),
1228
+ bool(openai_tools)
1229
+ )
1230
+
1231
+ # =====================================================
1232
+ # MESSAGES
1233
+ # =====================================================
1234
+
1235
+ openai_messages = normalize_messages(
1236
+ body.get("messages", [])
1237
+ )
1238
+
1239
+ system_prompt = body.get(
1240
+ "system",
1241
+ ""
1242
+ )
1243
+
1244
+ if system_prompt:
1245
+
1246
+ openai_messages.insert(
1247
+ 0,
1248
+ {
1249
+ "role": "system",
1250
+ "content": system_prompt
1251
+ }
1252
+ )
1253
+
1254
+ # =====================================================
1255
+ # PAYLOAD
1256
+ # =====================================================
1257
+
1258
+ payload = build_payload(
1259
+
1260
+ body,
1261
+
1262
+ openai_messages,
1263
+
1264
+ openai_tools,
1265
+
1266
+ openai_tool_choice,
1267
+
1268
+ mapped_model,
1269
+
1270
+ stream
1271
+ )
1272
+
1273
+ headers = {
1274
+
1275
+ **UPSTREAM_HEADERS,
1276
+
1277
+ "x-api-key": OPENCODE_API_KEY
1278
+ }
1279
 
1280
  logger.info(
1281
+
1282
  "[%s] %s -> %s | stream=%s | tools=%d",
1283
+
1284
+ request_id,
1285
+
1286
+ requested_model,
1287
+
1288
+ mapped_model,
1289
+
1290
+ stream,
1291
+
1292
+ len(openai_tools)
1293
  )
1294
 
1295
+ client = request.app.state.client
1296
+
1297
  last_error = None
1298
 
1299
+ # =====================================================
1300
+ # RETRIES
1301
+ # =====================================================
1302
+
1303
+ for attempt in range(
1304
+ 1,
1305
+ MAX_RETRIES + 1
1306
+ ):
1307
 
1308
  try:
1309
 
1310
+ # =================================================
1311
+ # STREAM MODE
1312
+ # =================================================
1313
+
1314
  if stream:
1315
 
1316
+ upstream_request = (
1317
+ client.build_request(
1318
+ "POST",
1319
+ UPSTREAM_URL,
1320
+ json=payload,
1321
+ headers=headers
1322
+ )
1323
+ )
1324
+
1325
+ response = await client.send(
1326
+ upstream_request,
1327
+ stream=True
1328
  )
 
1329
 
1330
+ # ---------------------------------------------
1331
+ # ERROR
1332
+ # ---------------------------------------------
1333
+
1334
+ if response.status_code != 200:
1335
+
1336
+ raw_error = (
1337
+ await response.aread()
1338
  ).decode(errors="ignore")
1339
+
1340
  logger.warning(
1341
+
1342
  "[%s] fail %d: %s",
1343
+
1344
+ request_id,
1345
+
1346
+ response.status_code,
1347
+
1348
+ raw_error[:200]
1349
  )
1350
+
1351
+ last_error = raw_error
1352
+
1353
+ await asyncio.sleep(
1354
+ RETRY_DELAY
1355
+ )
1356
+
1357
  continue
1358
 
1359
+ # ---------------------------------------------
1360
+ # SUCCESS
1361
+ # ---------------------------------------------
1362
+
1363
  return StreamingResponse(
1364
+
1365
+ convert_stream_to_anthropic(
1366
+ response,
1367
+ requested_model
1368
+ ),
1369
+
1370
  media_type="text/event-stream",
1371
+
1372
+ headers=STREAM_RESPONSE_HEADERS,
1373
  )
1374
 
1375
+ # =================================================
1376
+ # NORMAL MODE
1377
+ # =================================================
1378
+
1379
+ response = await client.post(
1380
+
1381
+ UPSTREAM_URL,
1382
+
1383
+ json=payload,
1384
+
1385
+ headers=headers
1386
  )
1387
 
1388
+ # -------------------------------------------------
1389
+ # ERROR
1390
+ # -------------------------------------------------
1391
+
1392
+ if response.status_code != 200:
1393
+
1394
  logger.warning(
1395
+
1396
+ "[%s] fail %d",
1397
+
1398
+ request_id,
1399
+
1400
+ response.status_code
1401
+ )
1402
+
1403
+ last_error = response.text
1404
+
1405
+ await asyncio.sleep(
1406
+ RETRY_DELAY
1407
  )
1408
+
 
1409
  continue
1410
 
1411
+ raw_response = (
1412
+ response.text.strip()
1413
+ )
1414
+
1415
+ if not raw_response:
1416
+
1417
  return JSONResponse(
1418
+
1419
  status_code=502,
1420
+
1421
+ content={
1422
+ "error": (
1423
+ "Empty upstream response"
1424
+ )
1425
+ }
1426
  )
1427
 
1428
  try:
1429
+
1430
+ parsed_response = (
1431
+ response.json()
1432
+ )
1433
+
1434
  except Exception:
1435
+
1436
  return JSONResponse(
1437
+
1438
  status_code=502,
1439
+
1440
+ content={
1441
+ "error": (
1442
+ "Invalid upstream JSON"
1443
+ )
1444
+ }
1445
  )
1446
 
1447
  return JSONResponse(
1448
+
1449
+ content=convert_to_anthropic(
1450
+ parsed_response,
1451
+ requested_model
1452
+ )
1453
  )
1454
 
1455
+ except Exception as error:
1456
+
1457
+ last_error = str(error)
1458
+
1459
+ logger.exception(
1460
+
1461
+ "[%s] exception",
1462
+
1463
+ request_id
1464
+ )
1465
+
1466
+ await asyncio.sleep(
1467
+ RETRY_DELAY
1468
+ )
1469
+
1470
+ # =====================================================
1471
+ # FINAL FAILURE
1472
+ # =====================================================
1473
 
1474
  return JSONResponse(
1475
+
1476
  status_code=502,
1477
+
1478
+ content={
1479
+
1480
+ "error": "Upstream failed",
1481
+
1482
+ "details": last_error
1483
+ }
1484
  )