bahi-bh commited on
Commit
892e0ce
·
verified ·
1 Parent(s): 2fe7d39

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +317 -328
app.py CHANGED
@@ -1,22 +1,12 @@
1
- """
2
- OpenCode Gateway — single-file Anthropic-compatible proxy
3
- Inspired by github.com/Alishahryar1/free-claude-code
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import asyncio
9
- import json
10
- import logging
11
  import os
 
12
  import uuid
 
 
13
  from contextlib import asynccontextmanager
14
- from dataclasses import dataclass, field
15
- from enum import Enum
16
- from typing import Any
17
 
18
  import httpx
19
- from fastapi import FastAPI, Header, Request
20
  from fastapi.responses import JSONResponse, StreamingResponse
21
 
22
  # =========================================================
@@ -25,9 +15,9 @@ from fastapi.responses import JSONResponse, StreamingResponse
25
 
26
  logging.basicConfig(
27
  level=logging.INFO,
28
- format="%(asctime)s [%(levelname)s] %(message)s",
29
  )
30
- logger = logging.getLogger("OpenCodeGateway")
31
 
32
  # =========================================================
33
  # CONFIG
@@ -40,9 +30,9 @@ RETRY_DELAY = 3
40
 
41
  UPSTREAM_HEADERS = {
42
  "Content-Type": "application/json",
43
- "Accept": "application/json",
44
- "Origin": "https://opencode.ai",
45
- "Referer": "https://opencode.ai/",
46
  "User-Agent": (
47
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
48
  "AppleWebKit/537.36 (KHTML, like Gecko) "
@@ -51,8 +41,8 @@ UPSTREAM_HEADERS = {
51
  }
52
 
53
  STREAM_HEADERS = {
54
- "Cache-Control": "no-cache",
55
- "Connection": "keep-alive",
56
  "X-Accel-Buffering": "no",
57
  }
58
 
@@ -60,14 +50,13 @@ STREAM_HEADERS = {
60
  # MODEL MAP
61
  # =========================================================
62
 
63
- MODEL_MAP: dict[str, str] = {
64
  "claude-sonnet-4-6": "deepseek-v4-flash-free",
65
  "claude-sonnet-4-5": "deepseek-v4-flash-free",
66
  "claude-haiku-4-6": "deepseek-v4-flash-free",
67
  "claude-haiku-4-5": "deepseek-v4-flash-free",
68
  "claude-haiku-4-5-20251001": "deepseek-v4-flash-free",
69
  "claude-opus-4-6": "nemotron-3-super-free",
70
- # direct aliases
71
  "deepseek-v4-flash-free": "deepseek-v4-flash-free",
72
  "nemotron-3-super-free": "nemotron-3-super-free",
73
  "qwen3.6-plus-free": "qwen3.6-plus-free",
@@ -80,125 +69,128 @@ DEFAULT_MODEL = "deepseek-v4-flash-free"
80
  _MODELS_CACHE = {
81
  "object": "list",
82
  "data": [
83
- {"id": m, "object": "model", "created": 1710000000, "owned_by": "gateway"}
 
 
 
 
 
84
  for m in MODEL_MAP
85
  ],
86
  }
87
 
88
  # =========================================================
89
- # THINK-TAG PARSER (ported from free-claude-code)
90
  # =========================================================
91
- # DeepSeek / Qwen sometimes wrap reasoning in <think>...</think>
92
- # instead of a reasoning_content field. We parse both.
93
-
94
- class _CType(Enum):
95
- TEXT = "text"
96
- THINKING = "thinking"
97
 
 
 
 
 
 
 
 
98
 
99
- @dataclass
100
- class _Chunk:
101
- type: _CType
102
- content: str
103
-
 
104
 
105
- class ThinkTagParser:
106
- """Streaming parser for <think>...</think> tags (chunk-boundary safe)."""
107
 
108
  OPEN = "<think>"
109
  CLOSE = "</think>"
110
 
111
- def __init__(self) -> None:
112
- self._buf: str = ""
113
- self._in: bool = False
114
 
115
- def feed(self, text: str):
 
116
  self._buf += text
117
  while self._buf:
118
  prev = len(self._buf)
119
- chunk = self._parse_inside() if self._in else self._parse_outside()
120
- if chunk:
121
- yield chunk
122
  elif len(self._buf) == prev:
123
  break
124
 
125
  def flush(self):
126
  if self._buf:
127
- yield _Chunk(_CType.THINKING if self._in else _CType.TEXT, self._buf)
128
  self._buf = ""
129
 
130
- def _parse_outside(self):
131
- idx = self._buf.find(self.OPEN)
132
- orphan = self._buf.find(self.CLOSE)
133
- if orphan != -1 and (idx == -1 or orphan < idx):
134
- pre = self._buf[:orphan]
135
- self._buf = self._buf[orphan + len(self.CLOSE):]
136
- return _Chunk(_CType.TEXT, pre) if pre else None
137
- if idx == -1:
 
 
 
 
 
 
 
 
138
  br = self._buf.rfind("<")
139
  if br != -1:
140
  tail = self._buf[br:]
141
- if len(tail) < len(self.OPEN) and self.OPEN.startswith(tail):
142
- emit, self._buf = self._buf[:br], self._buf[br:]
143
- return _Chunk(_CType.TEXT, emit) if emit else None
144
- emit, self._buf = self._buf, ""
145
- return _Chunk(_CType.TEXT, emit) if emit else None
146
- pre = self._buf[:idx]
147
- self._buf = self._buf[idx + len(self.OPEN):]
 
 
 
 
 
 
148
  self._in = True
149
- return _Chunk(_CType.TEXT, pre) if pre else None
150
 
151
- def _parse_inside(self):
152
- idx = self._buf.find(self.CLOSE)
153
- if idx == -1:
154
  br = self._buf.rfind("<")
155
- if br != -1 and len(self._buf) - br < len(self.CLOSE):
156
  tail = self._buf[br:]
157
- if self.CLOSE.startswith(tail):
158
- emit, self._buf = self._buf[:br], self._buf[br:]
159
- return _Chunk(_CType.THINKING, emit) if emit else None
160
- emit, self._buf = self._buf, ""
161
- return _Chunk(_CType.THINKING, emit) if emit else None
162
- thinking = self._buf[:idx]
163
- self._buf = self._buf[idx + len(self.CLOSE):]
164
- self._in = False
165
- return _Chunk(_CType.THINKING, thinking) if thinking else None
166
-
167
- # =========================================================
168
- # BROWSER TOOLS FILTER
169
- # =========================================================
170
- # OpenCode sends Chrome-extension tools that DeepSeek cannot
171
- # use. We strip them silently.
172
-
173
- _BROWSER_TOOLS = {
174
- "list_connected_browsers", "tabs_context_mcp", "browser_action",
175
- "screenshot", "puppeteer", "open_browser", "web_browser",
176
- "navigate", "click", "type_text", "get_page_content",
177
- "scroll", "wait_for_element",
178
- }
179
-
180
 
181
- def _filter_tools(tools: list) -> list:
182
- filtered = []
183
- removed = []
184
- for t in tools:
185
- name = t.get("name", "") if isinstance(t, dict) else ""
186
- if name in _BROWSER_TOOLS:
187
- removed.append(name)
188
- else:
189
- filtered.append(t)
190
- if removed:
191
- logger.info("Stripped browser tools: %s", removed)
192
- return filtered
193
 
194
  # =========================================================
195
  # SCHEMA FIX (DeepSeek rejects type:null)
196
  # =========================================================
197
 
198
- _VALID_TYPES = {"object", "array", "string", "number", "boolean", "integer"}
 
 
 
199
 
200
 
201
- def _fix_schema(schema: Any) -> dict:
202
  if not isinstance(schema, dict):
203
  return {"type": "object", "properties": {}}
204
  if schema.get("type") not in _VALID_TYPES:
@@ -209,22 +201,21 @@ def _fix_schema(schema: Any) -> dict:
209
 
210
  # =========================================================
211
  # REASONING EXTRACTION
 
 
212
  # =========================================================
213
- # Reads reasoning_content back from a "thinking" block so
214
- # DeepSeek receives it on the next turn (its requirement).
215
 
216
- def _extract_reasoning(msg: dict) -> str | None:
217
- # 1. top-level field
218
  rc = msg.get("reasoning_content")
219
  if isinstance(rc, str) and rc:
220
  return rc
221
- # 2. Anthropic thinking content block
222
  content = msg.get("content", "")
223
  if isinstance(content, list):
224
  parts = [
225
  item.get("thinking", "")
226
  for item in content
227
- if isinstance(item, dict) and item.get("type") == "thinking"
 
228
  ]
229
  joined = "".join(parts)
230
  if joined:
@@ -232,12 +223,10 @@ def _extract_reasoning(msg: dict) -> str | None:
232
  return None
233
 
234
  # =========================================================
235
- # NORMALIZE MESSAGES
236
  # =========================================================
237
- # Converts Anthropic message format → OpenAI format.
238
- # Key: preserves reasoning_content so DeepSeek is happy.
239
 
240
- def _system_to_str(raw: Any) -> str:
241
  if isinstance(raw, str):
242
  return raw or ""
243
  if isinstance(raw, list):
@@ -247,17 +236,21 @@ def _system_to_str(raw: Any) -> str:
247
  )
248
  return ""
249
 
 
 
 
 
 
250
 
251
- def normalize_messages(messages: list) -> list:
252
- output: list[dict] = []
253
 
254
  for msg in messages:
 
255
  role = msg.get("role", "user")
256
  content = msg.get("content", "")
257
 
258
- # ---- plain string ----
259
  if isinstance(content, str):
260
- entry: dict = {"role": role, "content": content}
261
  if role == "assistant":
262
  rc = _extract_reasoning(msg)
263
  if rc:
@@ -265,13 +258,14 @@ def normalize_messages(messages: list) -> list:
265
  output.append(entry)
266
  continue
267
 
268
- # ---- list of content blocks ----
269
  if isinstance(content, list):
270
- text_parts: list[str] = []
271
- tool_calls: list[dict] = []
272
- tool_results: list[dict] = []
 
273
 
274
  for item in content:
 
275
  if not isinstance(item, dict):
276
  text_parts.append(str(item))
277
  continue
@@ -284,36 +278,43 @@ def normalize_messages(messages: list) -> list:
284
  text_parts.append(txt)
285
 
286
  elif t == "thinking":
287
- pass # handled via _extract_reasoning
288
 
289
  elif t == "tool_use":
290
  tool_calls.append({
291
- "id": item.get("id", f"call_{uuid.uuid4().hex[:8]}"),
 
 
292
  "type": "function",
293
  "function": {
294
- "name": item.get("name", ""),
295
- "arguments": json.dumps(item.get("input", {})),
 
 
296
  },
297
  })
298
 
299
  elif t == "tool_result":
300
- rc_content = item.get("content", "")
301
- if isinstance(rc_content, list):
302
- rc_content = "\n".join(
303
- c.get("text", "") if isinstance(c, dict) else str(c)
304
- for c in rc_content
 
305
  )
306
  tool_results.append({
307
  "role": "tool",
308
  "tool_call_id": item.get("tool_use_id", ""),
309
- "content": str(rc_content),
310
  })
311
 
312
  if tool_calls:
313
- am: dict = {
314
  "role": "assistant",
315
  "tool_calls": tool_calls,
316
- "content": "\n".join(text_parts) if text_parts else None,
 
 
317
  }
318
  rc = _extract_reasoning(msg)
319
  if rc:
@@ -324,7 +325,10 @@ def normalize_messages(messages: list) -> list:
324
  output.extend(tool_results)
325
 
326
  else:
327
- entry = {"role": role, "content": "\n".join(text_parts)}
 
 
 
328
  if role == "assistant":
329
  rc = _extract_reasoning(msg)
330
  if rc:
@@ -338,25 +342,38 @@ def normalize_messages(messages: list) -> list:
338
  return output
339
 
340
  # =========================================================
341
- # TOOLS → OPENAI FORMAT
342
  # =========================================================
343
 
344
- def convert_tools(tools: list) -> list:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  result = []
346
- for tool in tools:
347
- schema = _fix_schema(tool.get("input_schema", {}))
348
  result.append({
349
  "type": "function",
350
  "function": {
351
- "name": tool.get("name", ""),
352
- "description": tool.get("description", ""),
353
- "parameters": schema,
354
  },
355
  })
356
  return result
357
 
358
 
359
- def map_tool_choice(raw: Any, has_tools: bool) -> Any:
360
  if not raw or not has_tools:
361
  return None
362
  if isinstance(raw, dict):
@@ -364,104 +381,30 @@ def map_tool_choice(raw: Any, has_tools: bool) -> Any:
364
  if t == "any":
365
  return "required"
366
  if t == "tool":
367
- return {"type": "function", "function": {"name": raw.get("name", "")}}
 
 
 
368
  return t
369
  return raw
370
 
371
  # =========================================================
372
- # SKILLS SYSTEM PROMPT
373
- # =========================================================
374
-
375
- def _build_skills_prompt(tools: list) -> str:
376
- if not tools:
377
- return ""
378
-
379
- names = {t.get("name", "") for t in tools if isinstance(t, dict)}
380
- sections: list[str] = []
381
-
382
- web = {"web_search", "search", "brave_search", "tavily_search"}
383
- if names & web:
384
- sections.append(
385
- "- WEB SEARCH: Search the internet in real time for current info, "
386
- "news, docs or prices. ALWAYS call the tool before answering."
387
- )
388
-
389
- exe = {"execute_command", "run_command", "bash", "shell", "terminal"}
390
- if names & exe:
391
- sections.append(
392
- "- CODE EXECUTION: Run shell commands directly. Use it to compile, "
393
- "test, install packages or perform any system operation."
394
- )
395
-
396
- fil = {"read_file", "write_file", "create_file", "edit_file", "list_directory"}
397
- if names & fil:
398
- sections.append(
399
- "- FILE SYSTEM: Read, write, create and edit files. "
400
- "When asked for a document, CSV or config file, create it on disk."
401
- )
402
-
403
- known = web | exe | fil | _BROWSER_TOOLS | {""}
404
- other = names - known
405
- if other:
406
- sections.append(
407
- f"- OTHER TOOLS: {', '.join(sorted(other))}. "
408
- "Use them whenever they are the most direct solution."
409
- )
410
-
411
- if not sections:
412
- return ""
413
-
414
- return "\n".join([
415
- "## Your capabilities in this session",
416
- "",
417
- "You have the following tools and MUST use them proactively:",
418
- "",
419
- *sections,
420
- "",
421
- "Always prefer calling a tool over saying you cannot do something.",
422
- ])
423
-
424
- # =========================================================
425
- # BUILD PAYLOAD
426
- # =========================================================
427
-
428
- def build_payload(
429
- body: dict,
430
- messages: list,
431
- tools: list,
432
- tool_choice: Any,
433
- model: str,
434
- stream: bool,
435
- ) -> dict:
436
- payload: dict = {
437
- "model": model,
438
- "messages": messages,
439
- "temperature": body.get("temperature", 0.7),
440
- "max_tokens": min(body.get("max_tokens", 32000), 64000),
441
- "stream": stream,
442
- }
443
- if tools:
444
- payload["tools"] = tools
445
- if tool_choice is not None:
446
- payload["tool_choice"] = tool_choice
447
- return payload
448
-
449
- # =========================================================
450
- # RESPONSE CONVERTER (non-stream)
451
  # =========================================================
452
 
453
  _FINISH_MAP = {"tool_calls": "tool_use", "length": "max_tokens"}
454
 
455
 
456
- def convert_response(parsed: dict, model: str) -> dict:
457
- blocks: list[dict] = []
 
458
  stop_reason = "end_turn"
459
 
460
  try:
461
  choices = parsed.get("choices") or []
462
  if choices:
463
- choice = choices[0]
464
- message = choice.get("message", {})
465
  stop_reason = _FINISH_MAP.get(
466
  choice.get("finish_reason"), "end_turn"
467
  )
@@ -487,8 +430,9 @@ def convert_response(parsed: dict, model: str) -> dict:
487
  "name": fn.get("name", ""),
488
  "input": args,
489
  })
 
490
  except Exception as err:
491
- logger.warning("convert_response error: %s", err)
492
 
493
  if not blocks:
494
  blocks.append({"type": "text", "text": ""})
@@ -511,45 +455,39 @@ def convert_response(parsed: dict, model: str) -> dict:
511
  # =========================================================
512
  # STREAM CONVERTER
513
  # =========================================================
514
- # Handles:
515
- # - reasoning_content field (DeepSeek native)
516
- # - <think>…</think> tags (Qwen / others)
517
- # Both are emitted as Anthropic "thinking" blocks so the
518
- # client stores them and echoes them back on the next turn.
519
 
520
- _EVT_MSG_START = "event: message_start\ndata: "
521
- _EVT_BLOCK_START = "event: content_block_start\ndata: "
522
- _EVT_BLOCK_DELTA = "event: content_block_delta\ndata: "
523
- _EVT_BLOCK_STOP = "event: content_block_stop\ndata: "
524
- _EVT_MSG_DELTA = "event: message_delta\ndata: "
525
- _EVT_MSG_STOP = "event: message_stop\ndata: "
526
 
527
 
528
- async def stream_convert(response: httpx.Response, model: str):
529
 
530
- msg_id = f"msg_{uuid.uuid4().hex}"
531
- in_tokens = 0
532
- out_tokens = 0
533
- stop_reason = "end_turn"
534
 
535
- # block index tracking
536
- thinking_idx: int | None = None
537
- text_idx: int | None = None
538
- tool_idx: dict[int, int] = {}
539
- tool_state: dict[int, dict] = {}
540
- next_idx = 0
541
 
542
- think_parser = ThinkTagParser()
543
 
544
- # ---- message_start ----
545
  yield (
546
- f"{_EVT_MSG_START}"
547
  + json.dumps({
548
  "type": "message_start",
549
  "message": {
550
- "id": msg_id, "type": "message", "role": "assistant",
551
- "model": model, "content": [],
552
- "stop_reason": None, "stop_sequence": None,
 
553
  "usage": {"input_tokens": 0, "output_tokens": 0},
554
  },
555
  })
@@ -557,14 +495,14 @@ async def stream_convert(response: httpx.Response, model: str):
557
  )
558
  yield 'event: ping\ndata: {"type":"ping"}\n\n'
559
 
560
- # -------- helpers --------
561
 
562
- def _open_thinking():
563
  nonlocal thinking_idx, next_idx
564
  thinking_idx = next_idx
565
  next_idx += 1
566
  return (
567
- f"{_EVT_BLOCK_START}"
568
  + json.dumps({
569
  "type": "content_block_start",
570
  "index": thinking_idx,
@@ -573,9 +511,9 @@ async def stream_convert(response: httpx.Response, model: str):
573
  + "\n\n"
574
  )
575
 
576
- def _delta_thinking(text: str) -> str:
577
  return (
578
- f"{_EVT_BLOCK_DELTA}"
579
  + json.dumps({
580
  "type": "content_block_delta",
581
  "index": thinking_idx,
@@ -584,12 +522,12 @@ async def stream_convert(response: httpx.Response, model: str):
584
  + "\n\n"
585
  )
586
 
587
- def _open_text():
588
  nonlocal text_idx, next_idx
589
  text_idx = next_idx
590
  next_idx += 1
591
  return (
592
- f"{_EVT_BLOCK_START}"
593
  + json.dumps({
594
  "type": "content_block_start",
595
  "index": text_idx,
@@ -598,9 +536,9 @@ async def stream_convert(response: httpx.Response, model: str):
598
  + "\n\n"
599
  )
600
 
601
- def _delta_text(text: str) -> str:
602
  return (
603
- f"{_EVT_BLOCK_DELTA}"
604
  + json.dumps({
605
  "type": "content_block_delta",
606
  "index": text_idx,
@@ -609,13 +547,15 @@ async def stream_convert(response: httpx.Response, model: str):
609
  + "\n\n"
610
  )
611
 
612
- # -------- main loop --------
613
  try:
 
614
  async for raw in response.aiter_lines():
 
615
  if not raw or not raw.startswith("data:"):
616
  continue
617
 
618
  line = raw[5:].strip()
 
619
  if line == "[DONE]":
620
  break
621
 
@@ -624,55 +564,55 @@ async def stream_convert(response: httpx.Response, model: str):
624
  except Exception:
625
  continue
626
 
627
- # usage
628
  if chunk.get("usage"):
629
- u = chunk["usage"]
630
- in_tokens = u.get("prompt_tokens", in_tokens)
631
- out_tokens = u.get("completion_tokens", out_tokens)
632
 
633
  choices = chunk.get("choices")
634
  if not choices:
635
  continue
636
 
637
- choice = choices[0]
638
- delta = choice.get("delta", {})
639
- finish = choice.get("finish_reason")
640
 
641
  if finish == "tool_calls":
642
  stop_reason = "tool_use"
643
  elif finish == "length":
644
  stop_reason = "max_tokens"
645
 
646
- # ---- reasoning_content (DeepSeek native field) ----
647
  rc = delta.get("reasoning_content")
648
  if rc:
649
  if thinking_idx is None:
650
- yield _open_thinking()
651
- yield _delta_thinking(rc)
652
  continue
653
 
654
- # ---- text content (may contain <think> tags) ----
655
  raw_text = delta.get("content") or ""
656
  if raw_text:
657
- for c in think_parser.feed(raw_text):
658
- if c.type == _CType.THINKING:
659
  if thinking_idx is None:
660
- yield _open_thinking()
661
- yield _delta_thinking(c.content)
662
  else:
663
  if text_idx is None:
664
- yield _open_text()
665
- yield _delta_text(c.content)
666
 
667
- # ---- tool calls ----
668
  for tc in delta.get("tool_calls") or []:
669
  idx = tc.get("index", 0)
670
 
671
  if idx not in tool_state:
672
  tool_state[idx] = {
673
- "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
 
 
674
  "name": "",
675
- "args": "",
676
  }
677
  blk = next_idx
678
  next_idx += 1
@@ -680,7 +620,7 @@ async def stream_convert(response: httpx.Response, model: str):
680
  fn_name = (tc.get("function") or {}).get("name", "")
681
  tool_state[idx]["name"] = fn_name
682
  yield (
683
- f"{_EVT_BLOCK_START}"
684
  + json.dumps({
685
  "type": "content_block_start",
686
  "index": blk,
@@ -694,16 +634,17 @@ async def stream_convert(response: httpx.Response, model: str):
694
  + "\n\n"
695
  )
696
 
697
- fn = tc.get("function") or {}
698
- args = fn.get("arguments", "")
699
  if args:
700
- tool_state[idx]["args"] += args
701
  yield (
702
- f"{_EVT_BLOCK_DELTA}"
703
  + json.dumps({
704
  "type": "content_block_delta",
705
  "index": tool_idx[idx],
706
- "delta": {"type": "input_json_delta", "partial_json": args},
 
 
 
707
  })
708
  + "\n\n"
709
  )
@@ -712,49 +653,68 @@ async def stream_convert(response: httpx.Response, model: str):
712
  logger.warning("stream error: %s", err)
713
 
714
  # flush leftover think-tag buffer
715
- for c in think_parser.flush() or []:
716
- if c.type == _CType.THINKING:
717
  if thinking_idx is None:
718
- yield _open_thinking()
719
- yield _delta_thinking(c.content)
720
  else:
721
  if text_idx is None:
722
- yield _open_text()
723
- yield _delta_text(c.content)
724
 
725
  # close blocks
726
- for idx in ([thinking_idx] if thinking_idx is not None else []):
727
  yield (
728
- f"{_EVT_BLOCK_STOP}"
729
- + json.dumps({"type": "content_block_stop", "index": idx})
 
 
 
730
  + "\n\n"
731
  )
 
732
  if text_idx is not None:
733
  yield (
734
- f"{_EVT_BLOCK_STOP}"
735
- + json.dumps({"type": "content_block_stop", "index": text_idx})
 
 
 
736
  + "\n\n"
737
  )
 
738
  for _, blk in tool_idx.items():
739
  yield (
740
- f"{_EVT_BLOCK_STOP}"
741
- + json.dumps({"type": "content_block_stop", "index": blk})
 
 
 
742
  + "\n\n"
743
  )
744
 
745
  yield (
746
- f"{_EVT_MSG_DELTA}"
747
  + json.dumps({
748
- "type": "message_delta",
749
- "delta": {"stop_reason": stop_reason, "stop_sequence": None},
 
 
 
750
  "usage": {"output_tokens": out_tokens},
751
  })
752
  + "\n\n"
753
  )
754
- yield f"{_EVT_MSG_STOP}" + json.dumps({"type": "message_stop"}) + "\n\n"
 
 
 
 
 
755
 
756
  # =========================================================
757
- # FASTAPI APP
758
  # =========================================================
759
 
760
  @asynccontextmanager
@@ -762,7 +722,10 @@ async def lifespan(app: FastAPI):
762
  app.state.client = httpx.AsyncClient(
763
  http2=False,
764
  timeout=httpx.Timeout(connect=20, read=300, write=60, pool=60),
765
- limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
 
 
 
766
  follow_redirects=True,
767
  )
768
  logger.info("Gateway started.")
@@ -809,7 +772,10 @@ async def messages(
809
  try:
810
  body = await request.json()
811
  except Exception:
812
- return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
 
 
 
813
 
814
  requested_model = body.get("model", "claude-sonnet-4-6")
815
  mapped_model = MODEL_MAP.get(requested_model, DEFAULT_MODEL)
@@ -819,45 +785,62 @@ async def messages(
819
  raw_tools = body.get("tools", [])
820
  clean_tools = _filter_tools(raw_tools)
821
  openai_tools = convert_tools(clean_tools)
822
- tool_choice = map_tool_choice(body.get("tool_choice"), bool(openai_tools))
 
 
823
 
824
  # messages
825
  oai_messages = normalize_messages(body.get("messages", []))
826
 
827
- # system prompt
828
- sys_str = _system_to_str(body.get("system", ""))
829
- skills_str = _build_skills_prompt(clean_tools)
830
- full_system = "\n\n---\n\n".join(filter(None, [skills_str, sys_str]))
831
- if full_system:
832
- oai_messages.insert(0, {"role": "system", "content": full_system})
 
 
 
 
 
 
 
 
 
 
 
833
 
834
- payload = build_payload(
835
- body, oai_messages, openai_tools, tool_choice, mapped_model, stream
836
- )
837
  headers = {**UPSTREAM_HEADERS, "x-api-key": OPENCODE_API_KEY}
838
 
839
  logger.info(
840
  "[%s] %s -> %s | stream=%s | tools=%d",
841
- req_id, requested_model, mapped_model, stream, len(openai_tools),
 
842
  )
843
 
844
  client = request.app.state.client
845
  last_error = None
846
 
847
  for attempt in range(1, MAX_RETRIES + 1):
 
848
  try:
849
 
850
  # ---------- STREAM ----------
851
  if stream:
 
852
  upstream_req = client.build_request(
853
- "POST", UPSTREAM_URL, json=payload, headers=headers
 
854
  )
855
  resp = await client.send(upstream_req, stream=True)
856
 
857
  if resp.status_code != 200:
858
- raw_err = (await resp.aread()).decode(errors="ignore")
 
 
859
  logger.warning(
860
- "[%s] fail %d: %s", req_id, resp.status_code, raw_err[:200]
 
861
  )
862
  last_error = raw_err
863
  await asyncio.sleep(RETRY_DELAY)
@@ -870,10 +853,14 @@ async def messages(
870
  )
871
 
872
  # ---------- NORMAL ----------
873
- resp = await client.post(UPSTREAM_URL, json=payload, headers=headers)
 
 
874
 
875
  if resp.status_code != 200:
876
- logger.warning("[%s] fail %d", req_id, resp.status_code)
 
 
877
  last_error = resp.text
878
  await asyncio.sleep(RETRY_DELAY)
879
  continue
@@ -892,7 +879,9 @@ async def messages(
892
  content={"error": "Invalid upstream JSON"},
893
  )
894
 
895
- return JSONResponse(content=convert_response(parsed, requested_model))
 
 
896
 
897
  except Exception as err:
898
  last_error = str(err)
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import json
3
  import uuid
4
+ import asyncio
5
+ import logging
6
  from contextlib import asynccontextmanager
 
 
 
7
 
8
  import httpx
9
+ from fastapi import FastAPI, Request, Header
10
  from fastapi.responses import JSONResponse, StreamingResponse
11
 
12
  # =========================================================
 
15
 
16
  logging.basicConfig(
17
  level=logging.INFO,
18
+ format="%(asctime)s [%(levelname)s] %(message)s"
19
  )
20
+ logger = logging.getLogger("gateway")
21
 
22
  # =========================================================
23
  # CONFIG
 
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) "
 
41
  }
42
 
43
  STREAM_HEADERS = {
44
+ "Cache-Control": "no-cache",
45
+ "Connection": "keep-alive",
46
  "X-Accel-Buffering": "no",
47
  }
48
 
 
50
  # MODEL MAP
51
  # =========================================================
52
 
53
+ MODEL_MAP = {
54
  "claude-sonnet-4-6": "deepseek-v4-flash-free",
55
  "claude-sonnet-4-5": "deepseek-v4-flash-free",
56
  "claude-haiku-4-6": "deepseek-v4-flash-free",
57
  "claude-haiku-4-5": "deepseek-v4-flash-free",
58
  "claude-haiku-4-5-20251001": "deepseek-v4-flash-free",
59
  "claude-opus-4-6": "nemotron-3-super-free",
 
60
  "deepseek-v4-flash-free": "deepseek-v4-flash-free",
61
  "nemotron-3-super-free": "nemotron-3-super-free",
62
  "qwen3.6-plus-free": "qwen3.6-plus-free",
 
69
  _MODELS_CACHE = {
70
  "object": "list",
71
  "data": [
72
+ {
73
+ "id": m,
74
+ "object": "model",
75
+ "created": 1710000000,
76
+ "owned_by": "gateway"
77
+ }
78
  for m in MODEL_MAP
79
  ],
80
  }
81
 
82
  # =========================================================
83
+ # BROWSER TOOLS — strip before sending to DeepSeek
84
  # =========================================================
 
 
 
 
 
 
85
 
86
+ _BROWSER_TOOLS = {
87
+ "list_connected_browsers", "tabs_context_mcp",
88
+ "browser_action", "screenshot", "puppeteer",
89
+ "open_browser", "web_browser", "navigate",
90
+ "click", "type_text", "get_page_content",
91
+ "scroll", "wait_for_element",
92
+ }
93
 
94
+ # =========================================================
95
+ # THINK-TAG PARSER
96
+ # Parses <think>…</think> tags from streaming text.
97
+ # Some models wrap reasoning in tags instead of a field.
98
+ # Ported from github.com/Alishahryar1/free-claude-code
99
+ # =========================================================
100
 
101
+ class _ThinkParser:
 
102
 
103
  OPEN = "<think>"
104
  CLOSE = "</think>"
105
 
106
+ def __init__(self):
107
+ self._buf = ""
108
+ self._in = False
109
 
110
+ def feed(self, text):
111
+ """Yield (is_thinking, chunk) tuples."""
112
  self._buf += text
113
  while self._buf:
114
  prev = len(self._buf)
115
+ result = self._step()
116
+ if result is not None:
117
+ yield result
118
  elif len(self._buf) == prev:
119
  break
120
 
121
  def flush(self):
122
  if self._buf:
123
+ yield (self._in, self._buf)
124
  self._buf = ""
125
 
126
+ def _step(self):
127
+ if self._in:
128
+ return self._inside()
129
+ return self._outside()
130
+
131
+ def _outside(self):
132
+ o = self._buf.find(self.OPEN)
133
+ c = self._buf.find(self.CLOSE)
134
+
135
+ # orphan close tag
136
+ if c != -1 and (o == -1 or c < o):
137
+ pre = self._buf[:c]
138
+ self._buf = self._buf[c + len(self.CLOSE):]
139
+ return (False, pre) if pre else None
140
+
141
+ if o == -1:
142
  br = self._buf.rfind("<")
143
  if br != -1:
144
  tail = self._buf[br:]
145
+ if (
146
+ len(tail) < len(self.OPEN)
147
+ and self.OPEN.startswith(tail)
148
+ ):
149
+ emit = self._buf[:br]
150
+ self._buf = self._buf[br:]
151
+ return (False, emit) if emit else None
152
+ emit = self._buf
153
+ self._buf = ""
154
+ return (False, emit) if emit else None
155
+
156
+ pre = self._buf[:o]
157
+ self._buf = self._buf[o + len(self.OPEN):]
158
  self._in = True
159
+ return (False, pre) if pre else None
160
 
161
+ def _inside(self):
162
+ c = self._buf.find(self.CLOSE)
163
+ if c == -1:
164
  br = self._buf.rfind("<")
165
+ if br != -1:
166
  tail = self._buf[br:]
167
+ if (
168
+ len(tail) < len(self.CLOSE)
169
+ and self.CLOSE.startswith(tail)
170
+ ):
171
+ emit = self._buf[:br]
172
+ self._buf = self._buf[br:]
173
+ return (True, emit) if emit else None
174
+ emit = self._buf
175
+ self._buf = ""
176
+ return (True, emit) if emit else None
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
+ thinking = self._buf[:c]
179
+ self._buf = self._buf[c + len(self.CLOSE):]
180
+ self._in = False
181
+ return (True, thinking) if thinking else None
 
 
 
 
 
 
 
 
182
 
183
  # =========================================================
184
  # SCHEMA FIX (DeepSeek rejects type:null)
185
  # =========================================================
186
 
187
+ _VALID_TYPES = {
188
+ "object", "array", "string",
189
+ "number", "boolean", "integer",
190
+ }
191
 
192
 
193
+ def _fix_schema(schema):
194
  if not isinstance(schema, dict):
195
  return {"type": "object", "properties": {}}
196
  if schema.get("type") not in _VALID_TYPES:
 
201
 
202
  # =========================================================
203
  # REASONING EXTRACTION
204
+ # Reads thinking back from previous assistant messages so
205
+ # DeepSeek receives reasoning_content on the next turn.
206
  # =========================================================
 
 
207
 
208
+ def _extract_reasoning(msg):
 
209
  rc = msg.get("reasoning_content")
210
  if isinstance(rc, str) and rc:
211
  return rc
 
212
  content = msg.get("content", "")
213
  if isinstance(content, list):
214
  parts = [
215
  item.get("thinking", "")
216
  for item in content
217
+ if isinstance(item, dict)
218
+ and item.get("type") == "thinking"
219
  ]
220
  joined = "".join(parts)
221
  if joined:
 
223
  return None
224
 
225
  # =========================================================
226
+ # SYSTEM HELPER
227
  # =========================================================
 
 
228
 
229
+ def _system_to_str(raw):
230
  if isinstance(raw, str):
231
  return raw or ""
232
  if isinstance(raw, list):
 
236
  )
237
  return ""
238
 
239
+ # =========================================================
240
+ # NORMALIZE MESSAGES
241
+ # =========================================================
242
+
243
+ def normalize_messages(messages):
244
 
245
+ output = []
 
246
 
247
  for msg in messages:
248
+
249
  role = msg.get("role", "user")
250
  content = msg.get("content", "")
251
 
 
252
  if isinstance(content, str):
253
+ entry = {"role": role, "content": content}
254
  if role == "assistant":
255
  rc = _extract_reasoning(msg)
256
  if rc:
 
258
  output.append(entry)
259
  continue
260
 
 
261
  if isinstance(content, list):
262
+
263
+ text_parts = []
264
+ tool_calls = []
265
+ tool_results = []
266
 
267
  for item in content:
268
+
269
  if not isinstance(item, dict):
270
  text_parts.append(str(item))
271
  continue
 
278
  text_parts.append(txt)
279
 
280
  elif t == "thinking":
281
+ pass # recovered via _extract_reasoning
282
 
283
  elif t == "tool_use":
284
  tool_calls.append({
285
+ "id": item.get(
286
+ "id", f"call_{uuid.uuid4().hex[:8]}"
287
+ ),
288
  "type": "function",
289
  "function": {
290
+ "name": item.get("name", ""),
291
+ "arguments": json.dumps(
292
+ item.get("input", {})
293
+ ),
294
  },
295
  })
296
 
297
  elif t == "tool_result":
298
+ rc_c = item.get("content", "")
299
+ if isinstance(rc_c, list):
300
+ rc_c = "\n".join(
301
+ c.get("text", "")
302
+ if isinstance(c, dict) else str(c)
303
+ for c in rc_c
304
  )
305
  tool_results.append({
306
  "role": "tool",
307
  "tool_call_id": item.get("tool_use_id", ""),
308
+ "content": str(rc_c),
309
  })
310
 
311
  if tool_calls:
312
+ am = {
313
  "role": "assistant",
314
  "tool_calls": tool_calls,
315
+ "content": (
316
+ "\n".join(text_parts) if text_parts else None
317
+ ),
318
  }
319
  rc = _extract_reasoning(msg)
320
  if rc:
 
325
  output.extend(tool_results)
326
 
327
  else:
328
+ entry = {
329
+ "role": role,
330
+ "content": "\n".join(text_parts),
331
+ }
332
  if role == "assistant":
333
  rc = _extract_reasoning(msg)
334
  if rc:
 
342
  return output
343
 
344
  # =========================================================
345
+ # TOOLS
346
  # =========================================================
347
 
348
+ def _filter_tools(tools):
349
+ filtered = []
350
+ removed = []
351
+ for t in tools:
352
+ name = t.get("name", "") if isinstance(t, dict) else ""
353
+ if name in _BROWSER_TOOLS:
354
+ removed.append(name)
355
+ else:
356
+ filtered.append(t)
357
+ if removed:
358
+ logger.info("Stripped browser tools: %s", removed)
359
+ return filtered
360
+
361
+
362
+ def convert_tools(tools):
363
  result = []
364
+ for t in tools:
 
365
  result.append({
366
  "type": "function",
367
  "function": {
368
+ "name": t.get("name", ""),
369
+ "description": t.get("description", ""),
370
+ "parameters": _fix_schema(t.get("input_schema", {})),
371
  },
372
  })
373
  return result
374
 
375
 
376
+ def map_tool_choice(raw, has_tools):
377
  if not raw or not has_tools:
378
  return None
379
  if isinstance(raw, dict):
 
381
  if t == "any":
382
  return "required"
383
  if t == "tool":
384
+ return {
385
+ "type": "function",
386
+ "function": {"name": raw.get("name", "")}
387
+ }
388
  return t
389
  return raw
390
 
391
  # =========================================================
392
+ # CONVERT NORMAL RESPONSE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  # =========================================================
394
 
395
  _FINISH_MAP = {"tool_calls": "tool_use", "length": "max_tokens"}
396
 
397
 
398
+ def convert_response(parsed, model):
399
+
400
+ blocks = []
401
  stop_reason = "end_turn"
402
 
403
  try:
404
  choices = parsed.get("choices") or []
405
  if choices:
406
+ choice = choices[0]
407
+ message = choice.get("message", {})
408
  stop_reason = _FINISH_MAP.get(
409
  choice.get("finish_reason"), "end_turn"
410
  )
 
430
  "name": fn.get("name", ""),
431
  "input": args,
432
  })
433
+
434
  except Exception as err:
435
+ logger.warning("convert_response: %s", err)
436
 
437
  if not blocks:
438
  blocks.append({"type": "text", "text": ""})
 
455
  # =========================================================
456
  # STREAM CONVERTER
457
  # =========================================================
 
 
 
 
 
458
 
459
+ _E_MSG_START = "event: message_start\ndata: "
460
+ _E_BLOCK_START = "event: content_block_start\ndata: "
461
+ _E_BLOCK_DELTA = "event: content_block_delta\ndata: "
462
+ _E_BLOCK_STOP = "event: content_block_stop\ndata: "
463
+ _E_MSG_DELTA = "event: message_delta\ndata: "
464
+ _E_MSG_STOP = "event: message_stop\ndata: "
465
 
466
 
467
+ async def stream_convert(response, model):
468
 
469
+ msg_id = f"msg_{uuid.uuid4().hex}"
470
+ out_tokens = 0
471
+ stop_reason = "end_turn"
 
472
 
473
+ thinking_idx = None
474
+ text_idx = None
475
+ tool_idx = {}
476
+ tool_state = {}
477
+ next_idx = 0
 
478
 
479
+ parser = _ThinkParser()
480
 
481
+ # message_start
482
  yield (
483
+ _E_MSG_START
484
  + json.dumps({
485
  "type": "message_start",
486
  "message": {
487
+ "id": msg_id, "type": "message",
488
+ "role": "assistant", "model": model,
489
+ "content": [], "stop_reason": None,
490
+ "stop_sequence": None,
491
  "usage": {"input_tokens": 0, "output_tokens": 0},
492
  },
493
  })
 
495
  )
496
  yield 'event: ping\ndata: {"type":"ping"}\n\n'
497
 
498
+ # helpers
499
 
500
+ def open_thinking():
501
  nonlocal thinking_idx, next_idx
502
  thinking_idx = next_idx
503
  next_idx += 1
504
  return (
505
+ _E_BLOCK_START
506
  + json.dumps({
507
  "type": "content_block_start",
508
  "index": thinking_idx,
 
511
  + "\n\n"
512
  )
513
 
514
+ def delta_thinking(text):
515
  return (
516
+ _E_BLOCK_DELTA
517
  + json.dumps({
518
  "type": "content_block_delta",
519
  "index": thinking_idx,
 
522
  + "\n\n"
523
  )
524
 
525
+ def open_text():
526
  nonlocal text_idx, next_idx
527
  text_idx = next_idx
528
  next_idx += 1
529
  return (
530
+ _E_BLOCK_START
531
  + json.dumps({
532
  "type": "content_block_start",
533
  "index": text_idx,
 
536
  + "\n\n"
537
  )
538
 
539
+ def delta_text(text):
540
  return (
541
+ _E_BLOCK_DELTA
542
  + json.dumps({
543
  "type": "content_block_delta",
544
  "index": text_idx,
 
547
  + "\n\n"
548
  )
549
 
 
550
  try:
551
+
552
  async for raw in response.aiter_lines():
553
+
554
  if not raw or not raw.startswith("data:"):
555
  continue
556
 
557
  line = raw[5:].strip()
558
+
559
  if line == "[DONE]":
560
  break
561
 
 
564
  except Exception:
565
  continue
566
 
 
567
  if chunk.get("usage"):
568
+ out_tokens = chunk["usage"].get(
569
+ "completion_tokens", out_tokens
570
+ )
571
 
572
  choices = chunk.get("choices")
573
  if not choices:
574
  continue
575
 
576
+ choice = choices[0]
577
+ delta = choice.get("delta", {})
578
+ finish = choice.get("finish_reason")
579
 
580
  if finish == "tool_calls":
581
  stop_reason = "tool_use"
582
  elif finish == "length":
583
  stop_reason = "max_tokens"
584
 
585
+ # reasoning_content field (DeepSeek native)
586
  rc = delta.get("reasoning_content")
587
  if rc:
588
  if thinking_idx is None:
589
+ yield open_thinking()
590
+ yield delta_thinking(rc)
591
  continue
592
 
593
+ # text content — may contain <think> tags
594
  raw_text = delta.get("content") or ""
595
  if raw_text:
596
+ for is_thinking, chunk_text in parser.feed(raw_text):
597
+ if is_thinking:
598
  if thinking_idx is None:
599
+ yield open_thinking()
600
+ yield delta_thinking(chunk_text)
601
  else:
602
  if text_idx is None:
603
+ yield open_text()
604
+ yield delta_text(chunk_text)
605
 
606
+ # tool calls
607
  for tc in delta.get("tool_calls") or []:
608
  idx = tc.get("index", 0)
609
 
610
  if idx not in tool_state:
611
  tool_state[idx] = {
612
+ "id": tc.get(
613
+ "id", f"toolu_{uuid.uuid4().hex[:8]}"
614
+ ),
615
  "name": "",
 
616
  }
617
  blk = next_idx
618
  next_idx += 1
 
620
  fn_name = (tc.get("function") or {}).get("name", "")
621
  tool_state[idx]["name"] = fn_name
622
  yield (
623
+ _E_BLOCK_START
624
  + json.dumps({
625
  "type": "content_block_start",
626
  "index": blk,
 
634
  + "\n\n"
635
  )
636
 
637
+ args = (tc.get("function") or {}).get("arguments", "")
 
638
  if args:
 
639
  yield (
640
+ _E_BLOCK_DELTA
641
  + json.dumps({
642
  "type": "content_block_delta",
643
  "index": tool_idx[idx],
644
+ "delta": {
645
+ "type": "input_json_delta",
646
+ "partial_json": args,
647
+ },
648
  })
649
  + "\n\n"
650
  )
 
653
  logger.warning("stream error: %s", err)
654
 
655
  # flush leftover think-tag buffer
656
+ for is_thinking, chunk_text in (parser.flush() or []):
657
+ if is_thinking:
658
  if thinking_idx is None:
659
+ yield open_thinking()
660
+ yield delta_thinking(chunk_text)
661
  else:
662
  if text_idx is None:
663
+ yield open_text()
664
+ yield delta_text(chunk_text)
665
 
666
  # close blocks
667
+ if thinking_idx is not None:
668
  yield (
669
+ _E_BLOCK_STOP
670
+ + json.dumps({
671
+ "type": "content_block_stop",
672
+ "index": thinking_idx,
673
+ })
674
  + "\n\n"
675
  )
676
+
677
  if text_idx is not None:
678
  yield (
679
+ _E_BLOCK_STOP
680
+ + json.dumps({
681
+ "type": "content_block_stop",
682
+ "index": text_idx,
683
+ })
684
  + "\n\n"
685
  )
686
+
687
  for _, blk in tool_idx.items():
688
  yield (
689
+ _E_BLOCK_STOP
690
+ + json.dumps({
691
+ "type": "content_block_stop",
692
+ "index": blk,
693
+ })
694
  + "\n\n"
695
  )
696
 
697
  yield (
698
+ _E_MSG_DELTA
699
  + json.dumps({
700
+ "type": "message_delta",
701
+ "delta": {
702
+ "stop_reason": stop_reason,
703
+ "stop_sequence": None,
704
+ },
705
  "usage": {"output_tokens": out_tokens},
706
  })
707
  + "\n\n"
708
  )
709
+
710
+ yield (
711
+ _E_MSG_STOP
712
+ + json.dumps({"type": "message_stop"})
713
+ + "\n\n"
714
+ )
715
 
716
  # =========================================================
717
+ # FASTAPI
718
  # =========================================================
719
 
720
  @asynccontextmanager
 
722
  app.state.client = httpx.AsyncClient(
723
  http2=False,
724
  timeout=httpx.Timeout(connect=20, read=300, write=60, pool=60),
725
+ limits=httpx.Limits(
726
+ max_connections=100,
727
+ max_keepalive_connections=20,
728
+ ),
729
  follow_redirects=True,
730
  )
731
  logger.info("Gateway started.")
 
772
  try:
773
  body = await request.json()
774
  except Exception:
775
+ return JSONResponse(
776
+ status_code=400,
777
+ content={"error": "Invalid JSON"},
778
+ )
779
 
780
  requested_model = body.get("model", "claude-sonnet-4-6")
781
  mapped_model = MODEL_MAP.get(requested_model, DEFAULT_MODEL)
 
785
  raw_tools = body.get("tools", [])
786
  clean_tools = _filter_tools(raw_tools)
787
  openai_tools = convert_tools(clean_tools)
788
+ tool_choice = map_tool_choice(
789
+ body.get("tool_choice"), bool(openai_tools)
790
+ )
791
 
792
  # messages
793
  oai_messages = normalize_messages(body.get("messages", []))
794
 
795
+ # system
796
+ sys_str = _system_to_str(body.get("system", ""))
797
+ if sys_str:
798
+ oai_messages.insert(0, {"role": "system", "content": sys_str})
799
+
800
+ # payload
801
+ payload = {
802
+ "model": mapped_model,
803
+ "messages": oai_messages,
804
+ "temperature": body.get("temperature", 0.7),
805
+ "max_tokens": min(body.get("max_tokens", 32000), 64000),
806
+ "stream": stream,
807
+ }
808
+ if openai_tools:
809
+ payload["tools"] = openai_tools
810
+ if tool_choice is not None:
811
+ payload["tool_choice"] = tool_choice
812
 
 
 
 
813
  headers = {**UPSTREAM_HEADERS, "x-api-key": OPENCODE_API_KEY}
814
 
815
  logger.info(
816
  "[%s] %s -> %s | stream=%s | tools=%d",
817
+ req_id, requested_model, mapped_model,
818
+ stream, len(openai_tools),
819
  )
820
 
821
  client = request.app.state.client
822
  last_error = None
823
 
824
  for attempt in range(1, MAX_RETRIES + 1):
825
+
826
  try:
827
 
828
  # ---------- STREAM ----------
829
  if stream:
830
+
831
  upstream_req = client.build_request(
832
+ "POST", UPSTREAM_URL,
833
+ json=payload, headers=headers,
834
  )
835
  resp = await client.send(upstream_req, stream=True)
836
 
837
  if resp.status_code != 200:
838
+ raw_err = (
839
+ await resp.aread()
840
+ ).decode(errors="ignore")
841
  logger.warning(
842
+ "[%s] fail %d: %s",
843
+ req_id, resp.status_code, raw_err[:200],
844
  )
845
  last_error = raw_err
846
  await asyncio.sleep(RETRY_DELAY)
 
853
  )
854
 
855
  # ---------- NORMAL ----------
856
+ resp = await client.post(
857
+ UPSTREAM_URL, json=payload, headers=headers,
858
+ )
859
 
860
  if resp.status_code != 200:
861
+ logger.warning(
862
+ "[%s] fail %d", req_id, resp.status_code
863
+ )
864
  last_error = resp.text
865
  await asyncio.sleep(RETRY_DELAY)
866
  continue
 
879
  content={"error": "Invalid upstream JSON"},
880
  )
881
 
882
+ return JSONResponse(
883
+ content=convert_response(parsed, requested_model)
884
+ )
885
 
886
  except Exception as err:
887
  last_error = str(err)