bahi-bh commited on
Commit
4fa2bd8
·
verified ·
1 Parent(s): fa28738

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +109 -556
app.py CHANGED
@@ -1,14 +1,8 @@
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
@@ -31,20 +25,13 @@ logger = logging.getLogger("OpenCodeGateway")
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",
@@ -70,15 +57,13 @@ STREAM_RESPONSE_HEADERS = {
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 = "nemotron-3-super-free"
82
 
83
  _MODELS_CACHE = {
84
  "object": "list",
@@ -102,19 +87,16 @@ 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
 
@@ -128,13 +110,12 @@ async def lifespan(app: FastAPI):
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"
@@ -143,7 +124,6 @@ async def root():
143
 
144
  @app.get("/health")
145
  async def health():
146
-
147
  return {
148
  "status": "healthy"
149
  }
@@ -151,47 +131,30 @@ async def health():
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):
197
 
@@ -200,7 +163,6 @@ def normalize_messages(messages):
200
  for msg in messages:
201
 
202
  role = msg.get("role", "user")
203
-
204
  content = msg.get("content", "")
205
 
206
  # -------------------------------------------------
@@ -209,40 +171,27 @@ def normalize_messages(messages):
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", "")
@@ -256,7 +205,6 @@ def normalize_messages(messages):
256
  txt = item.get("text", "")
257
 
258
  if txt:
259
-
260
  text_parts.append(txt)
261
 
262
  # -----------------------------------------
@@ -266,21 +214,13 @@ def normalize_messages(messages):
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
  ),
@@ -305,29 +245,23 @@ def normalize_messages(messages):
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
@@ -335,45 +269,18 @@ def normalize_messages(messages):
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
 
@@ -381,22 +288,28 @@ def normalize_messages(messages):
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
 
401
  # =========================================================
402
  # TOOLS
@@ -411,27 +324,14 @@ def convert_tools_to_openai(tools):
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
 
@@ -443,37 +343,24 @@ def convert_tools_to_openai(tools):
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
 
@@ -495,21 +382,13 @@ def build_payload(
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
 
@@ -518,16 +397,12 @@ def build_payload(
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 = {
@@ -535,17 +410,12 @@ _FINISH_MAP = {
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")
@@ -554,77 +424,43 @@ def convert_to_anthropic(
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
 
@@ -638,40 +474,25 @@ def convert_to_anthropic(
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
@@ -679,19 +500,8 @@ def convert_to_anthropic(
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: "
@@ -701,11 +511,6 @@ _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
@@ -716,34 +521,18 @@ async def convert_stream_to_anthropic(
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
  },
@@ -755,12 +544,9 @@ async def convert_stream_to_anthropic(
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
@@ -773,10 +559,7 @@ async def convert_stream_to_anthropic(
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()
@@ -785,17 +568,11 @@ async def convert_stream_to_anthropic(
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"]
@@ -817,69 +594,35 @@ async def convert_stream_to_anthropic(
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
  }
@@ -891,15 +634,10 @@ async def convert_stream_to_anthropic(
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
  }
@@ -914,64 +652,44 @@ async def convert_stream_to_anthropic(
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
  }
@@ -982,42 +700,25 @@ async def convert_stream_to_anthropic(
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
  }
@@ -1035,16 +736,14 @@ async def convert_stream_to_anthropic(
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
 
@@ -1054,16 +753,10 @@ async def convert_stream_to_anthropic(
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
 
@@ -1073,51 +766,13 @@ async def convert_stream_to_anthropic(
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
  }
@@ -1128,12 +783,7 @@ async def convert_stream_to_anthropic(
1128
  "\n\n"
1129
  )
1130
 
1131
- # =====================================================
1132
- # STOP
1133
- # =====================================================
1134
-
1135
  stop_payload = {
1136
-
1137
  "type": "message_stop"
1138
  }
1139
 
@@ -1156,27 +806,15 @@ async def messages(
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()
@@ -1184,18 +822,12 @@ async def messages(
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"
@@ -1210,14 +842,11 @@ async def messages(
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
@@ -1228,18 +857,15 @@ async def messages(
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
 
@@ -1251,44 +877,30 @@ async def messages(
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
 
@@ -1296,30 +908,25 @@ async def messages(
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(
@@ -1327,10 +934,6 @@ async def messages(
1327
  stream=True
1328
  )
1329
 
1330
- # ---------------------------------------------
1331
- # ERROR
1332
- # ---------------------------------------------
1333
-
1334
  if response.status_code != 200:
1335
 
1336
  raw_error = (
@@ -1338,114 +941,76 @@ async def messages(
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
@@ -1457,28 +1022,16 @@ async def messages(
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
  )
 
 
 
 
 
 
1
  import os
2
  import json
3
  import uuid
4
  import asyncio
5
  import logging
 
6
  from contextlib import asynccontextmanager
7
 
8
  import httpx
 
25
  # CONFIG
26
  # =========================================================
27
 
28
+ OPENCODE_API_KEY = os.getenv("OPENCODE_API_KEY", "").strip()
 
 
 
29
 
30
  UPSTREAM_URL = "https://opencode.ai/zen/v1/chat/completions"
31
 
32
  MAX_RETRIES = 2
33
  RETRY_DELAY = 1
34
 
 
 
 
 
35
  UPSTREAM_HEADERS = {
36
  "Content-Type": "application/json",
37
  "Accept": "application/json",
 
57
  MODEL_MAP = {
58
  "claude-sonnet-4-6": "deepseek-v4-flash-free",
59
  "claude-sonnet-4-5": "deepseek-v4-flash-free",
60
+ "claude-haiku-4-6": "deepseek-v4-flash-free",
61
+ "claude-haiku-4-5": "deepseek-v4-flash-free",
 
62
  "claude-haiku-4-5-20251001": "deepseek-v4-flash-free",
 
63
  "claude-opus-4-6": "nemotron-3-super-free",
64
  }
65
 
66
+ DEFAULT_MODEL = "deepseek-v4-flash-free"
67
 
68
  _MODELS_CACHE = {
69
  "object": "list",
 
87
 
88
  app.state.client = httpx.AsyncClient(
89
  http2=False,
 
90
  timeout=httpx.Timeout(
91
  connect=20,
92
  read=300,
93
  write=60,
94
  pool=60
95
  ),
 
96
  limits=httpx.Limits(
97
  max_connections=100,
98
  max_keepalive_connections=20
99
  ),
 
100
  follow_redirects=True,
101
  )
102
 
 
110
  app = FastAPI(lifespan=lifespan)
111
 
112
  # =========================================================
113
+ # ROOT / HEALTH / MODELS
114
  # =========================================================
115
 
116
  @app.get("/")
117
  @app.head("/")
118
  async def root():
 
119
  return {
120
  "status": "ok",
121
  "service": "OpenCode Gateway"
 
124
 
125
  @app.get("/health")
126
  async def health():
 
127
  return {
128
  "status": "healthy"
129
  }
 
131
 
132
  @app.get("/v1/models")
133
  async def get_models():
 
134
  return _MODELS_CACHE
135
 
136
  # =========================================================
137
  # FIX TOOL SCHEMA
138
  # =========================================================
139
+ # DeepSeek rejects tools whose input_schema has type=null.
140
+ # We patch any schema that is missing a valid "type" field
141
+ # to be type=object so the provider accepts it.
 
 
 
 
 
 
142
 
143
  def _fix_schema(schema):
144
+ """Ensure schema has type=object at root (DeepSeek requirement)."""
145
  if not isinstance(schema, dict):
146
+ return {"type": "object", "properties": {}}
147
+ if schema.get("type") not in ("object", "array", "string", "number", "boolean", "integer"):
148
+ schema = dict(schema)
149
+ schema["type"] = "object"
150
+ schema.setdefault("properties", {})
151
+ return schema
 
 
 
 
 
 
 
 
 
 
 
152
 
153
  # =========================================================
154
  # NORMALIZE MESSAGES
 
155
  # =========================================================
156
+ # Also strips `reasoning_content` from assistant messages
157
+ # (DeepSeek thinking mode artifact that must not be echoed back).
158
 
159
  def normalize_messages(messages):
160
 
 
163
  for msg in messages:
164
 
165
  role = msg.get("role", "user")
 
166
  content = msg.get("content", "")
167
 
168
  # -------------------------------------------------
 
171
 
172
  if isinstance(content, str):
173
 
174
+ output.append({
175
  "role": role,
176
  "content": content
177
+ })
 
 
 
 
 
 
 
 
 
178
 
179
  continue
180
 
181
  # -------------------------------------------------
182
+ # LIST CONTENT
183
  # -------------------------------------------------
184
 
185
  if isinstance(content, list):
186
 
187
  text_parts = []
 
188
  tool_calls = []
 
189
  tool_results = []
190
 
191
  for item in content:
192
 
193
  if not isinstance(item, dict):
 
194
  text_parts.append(str(item))
 
195
  continue
196
 
197
  item_type = item.get("type", "")
 
205
  txt = item.get("text", "")
206
 
207
  if txt:
 
208
  text_parts.append(txt)
209
 
210
  # -----------------------------------------
 
214
  elif item_type == "tool_use":
215
 
216
  tool_calls.append({
 
217
  "id": item.get(
218
  "id",
219
  f"call_{uuid.uuid4().hex[:8]}"
220
  ),
 
221
  "type": "function",
 
222
  "function": {
223
+ "name": item.get("name", ""),
 
 
 
 
 
224
  "arguments": json.dumps(
225
  item.get("input", {})
226
  ),
 
245
  )
246
 
247
  tool_results.append({
 
248
  "role": "tool",
 
249
  "tool_call_id": item.get(
250
  "tool_use_id",
251
  ""
252
  ),
 
253
  "content": str(rc),
254
  })
255
 
256
  # ---------------------------------------------
257
+ # FINALIZE MESSAGE
258
  # ---------------------------------------------
259
 
260
  if tool_calls:
261
 
262
  assistant_msg = {
 
263
  "role": "assistant",
 
264
  "tool_calls": tool_calls,
 
265
  "content": (
266
  "\n".join(text_parts)
267
  if text_parts
 
269
  )
270
  }
271
 
 
 
 
 
 
 
 
 
 
272
  output.append(assistant_msg)
273
 
 
 
 
 
274
  elif tool_results:
275
 
276
  output.extend(tool_results)
277
 
 
 
 
 
278
  else:
279
 
280
+ output.append({
 
281
  "role": role,
 
282
  "content": "\n".join(text_parts)
283
+ })
 
 
 
 
 
 
 
 
284
 
285
  continue
286
 
 
288
  # FALLBACK
289
  # -------------------------------------------------
290
 
291
+ output.append({
 
292
  "role": role,
 
293
  "content": str(content)
294
+ })
295
 
296
+ # -----------------------------------------------------
297
+ # STRIP reasoning_content FROM EVERY ASSISTANT MESSAGE
298
+ # -----------------------------------------------------
299
+ # DeepSeek thinking mode puts reasoning_content in the
300
+ # response. If it gets echoed back in the next turn,
301
+ # DeepSeek returns 400. We remove it unconditionally.
302
 
303
+ clean = []
304
+
305
+ for msg in output:
306
 
307
+ if msg.get("role") == "assistant" and "reasoning_content" in msg:
308
+ msg = {k: v for k, v in msg.items() if k != "reasoning_content"}
309
 
310
+ clean.append(msg)
311
+
312
+ return clean
313
 
314
  # =========================================================
315
  # TOOLS
 
324
 
325
  for tool in tools:
326
 
327
+ schema = _fix_schema(tool.get("input_schema", {}))
 
 
328
 
329
  result.append({
 
330
  "type": "function",
 
331
  "function": {
332
+ "name": tool.get("name", ""),
333
+ "description": tool.get("description", ""),
334
+ "parameters": schema,
 
 
 
 
 
 
 
 
 
335
  },
336
  })
337
 
 
343
 
344
  _TOOL_CHOICE_ANY = "required"
345
 
346
+ def map_tool_choice(tool_choice_raw, has_tools):
 
 
 
347
 
348
  if not tool_choice_raw or not has_tools:
 
349
  return None
350
 
351
  if isinstance(tool_choice_raw, dict):
352
 
353
+ tool_type = tool_choice_raw.get("type", "auto")
 
 
 
354
 
355
  if tool_type == "any":
 
356
  return _TOOL_CHOICE_ANY
357
 
358
  if tool_type == "tool":
359
 
360
  return {
 
361
  "type": "function",
 
362
  "function": {
363
+ "name": tool_choice_raw.get("name", "")
 
 
 
364
  }
365
  }
366
 
 
382
  ):
383
 
384
  payload = {
 
385
  "model": mapped_model,
 
386
  "messages": openai_messages,
387
+ "temperature": body.get("temperature", 0.7),
 
 
 
 
 
388
  "max_tokens": min(
389
  body.get("max_tokens", 32000),
390
  64000
391
  ),
 
392
  "stream": stream,
393
  }
394
 
 
397
  payload["tools"] = openai_tools
398
 
399
  if openai_tool_choice is not None:
400
+ payload["tool_choice"] = openai_tool_choice
 
 
 
401
 
402
  return payload
403
 
404
  # =========================================================
405
  # CONVERT NORMAL RESPONSE
 
406
  # =========================================================
407
 
408
  _FINISH_MAP = {
 
410
  "length": "max_tokens"
411
  }
412
 
413
+ def convert_to_anthropic(parsed, requested_model):
 
 
 
414
 
415
  content_blocks = []
416
 
417
  stop_reason = "end_turn"
418
 
 
 
419
  try:
420
 
421
  choices = parsed.get("choices")
 
424
 
425
  choice = choices[0]
426
 
427
+ message = choice.get("message", {})
 
 
 
428
 
429
  stop_reason = _FINISH_MAP.get(
430
  choice.get("finish_reason"),
431
  "end_turn"
432
  )
433
 
434
+ text = message.get("content") or ""
 
 
 
 
 
 
 
 
 
435
 
436
  if text:
437
 
438
  content_blocks.append({
 
439
  "type": "text",
 
440
  "text": text
441
  })
442
 
443
+ for tc in (message.get("tool_calls") or []):
 
 
 
444
 
445
+ fn = tc.get("function", {})
 
 
 
446
 
447
+ raw_args = fn.get("arguments", "{}")
 
 
 
448
 
449
  try:
450
+ parsed_args = json.loads(raw_args)
 
 
 
451
 
452
  except Exception:
 
453
  parsed_args = {
454
  "raw": raw_args
455
  }
456
 
457
  content_blocks.append({
 
458
  "type": "tool_use",
 
459
  "id": tc.get(
460
  "id",
461
  f"toolu_{uuid.uuid4().hex[:8]}"
462
  ),
463
+ "name": fn.get("name", ""),
 
 
 
 
 
464
  "input": parsed_args,
465
  })
466
 
 
474
  if not content_blocks:
475
 
476
  content_blocks.append({
 
477
  "type": "text",
 
478
  "text": ""
479
  })
480
 
481
+ usage = parsed.get("usage", {})
 
 
 
 
 
482
 
483
+ return {
484
  "id": f"msg_{uuid.uuid4().hex}",
 
485
  "type": "message",
 
486
  "role": "assistant",
 
487
  "model": requested_model,
 
488
  "content": content_blocks,
 
489
  "stop_reason": stop_reason,
 
490
  "stop_sequence": None,
 
491
  "usage": {
 
492
  "input_tokens": usage.get(
493
  "prompt_tokens",
494
  0
495
  ),
 
496
  "output_tokens": usage.get(
497
  "completion_tokens",
498
  0
 
500
  },
501
  }
502
 
 
 
 
 
 
 
 
 
 
 
 
503
  # =========================================================
504
+ # STREAM CONVERSION
505
  # =========================================================
506
 
507
  _STREAM_MSG_START = "event: message_start\ndata: "
 
511
  _STREAM_MSG_DELTA = "event: message_delta\ndata: "
512
  _STREAM_MSG_STOP = "event: message_stop\ndata: "
513
 
 
 
 
 
 
514
  async def convert_stream_to_anthropic(
515
  response,
516
  requested_model
 
521
  input_tokens = 0
522
  output_tokens = 0
523
 
 
 
524
  start_payload = {
 
525
  "type": "message_start",
 
526
  "message": {
 
527
  "id": msg_id,
 
528
  "type": "message",
 
529
  "role": "assistant",
 
530
  "model": requested_model,
 
531
  "content": [],
 
 
 
532
  "stop_reason": None,
 
533
  "stop_sequence": None,
 
534
  "usage": {
 
535
  "input_tokens": 0,
 
536
  "output_tokens": 0,
537
  },
538
  },
 
544
  "\n\n"
545
  )
546
 
547
+ yield 'event: ping\ndata: {"type":"ping"}\n\n'
 
 
548
 
549
  tool_state = {}
 
550
  tool_block_idx = {}
551
 
552
  text_block_idx = None
 
559
 
560
  async for raw in response.aiter_lines():
561
 
562
+ if not raw or not raw.startswith("data:"):
 
 
 
563
  continue
564
 
565
  line = raw[5:].strip()
 
568
  break
569
 
570
  try:
 
571
  chunk = json.loads(line)
572
 
573
  except Exception:
 
574
  continue
575
 
 
 
 
 
576
  if chunk.get("usage"):
577
 
578
  usage = chunk["usage"]
 
594
 
595
  choice = choices[0]
596
 
597
+ delta = choice.get("delta", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
 
599
+ finish_reason = choice.get("finish_reason")
 
 
 
 
 
 
600
 
601
  if finish_reason == "tool_calls":
 
602
  stop_reason = "tool_use"
603
 
604
  elif finish_reason == "length":
 
605
  stop_reason = "max_tokens"
606
 
607
  # -------------------------------------------------
608
  # TEXT DELTA
609
+ # Skip reasoning_content keys at stream level too
610
  # -------------------------------------------------
611
 
612
+ text_delta = delta.get("content")
 
 
613
 
614
  if text_delta:
615
 
616
  if text_block_idx is None:
617
 
618
  text_block_idx = next_idx
 
619
  next_idx += 1
620
 
621
  block_payload = {
 
622
  "type": "content_block_start",
 
623
  "index": text_block_idx,
 
624
  "content_block": {
 
625
  "type": "text",
 
626
  "text": ""
627
  }
628
  }
 
634
  )
635
 
636
  delta_payload = {
 
637
  "type": "content_block_delta",
 
638
  "index": text_block_idx,
 
639
  "delta": {
 
640
  "type": "text_delta",
 
641
  "text": text_delta
642
  }
643
  }
 
652
  # TOOL CALLS
653
  # -------------------------------------------------
654
 
655
+ tool_call_deltas = delta.get("tool_calls")
 
 
656
 
657
  if tool_call_deltas:
658
 
659
  for tc_delta in tool_call_deltas:
660
 
661
+ idx = tc_delta.get("index", 0)
 
 
 
662
 
663
  if idx not in tool_state:
664
 
665
  tool_state[idx] = {
 
666
  "id": tc_delta.get(
667
  "id",
668
  f"toolu_{uuid.uuid4().hex[:8]}"
669
  ),
 
670
  "name": "",
 
671
  "args": "",
672
  }
673
 
674
  block_index = next_idx
 
675
  next_idx += 1
676
 
677
+ tool_block_idx[idx] = block_index
 
 
678
 
679
+ fn = tc_delta.get("function", {})
 
 
 
680
 
681
+ tool_state[idx]["name"] = fn.get(
682
+ "name",
683
+ ""
684
  )
685
 
686
  block_payload = {
 
687
  "type": "content_block_start",
 
688
  "index": block_index,
 
689
  "content_block": {
 
690
  "type": "tool_use",
 
691
  "id": tool_state[idx]["id"],
 
692
  "name": tool_state[idx]["name"],
 
693
  "input": {}
694
  }
695
  }
 
700
  "\n\n"
701
  )
702
 
703
+ fn = tc_delta.get("function", {})
 
 
 
704
 
705
  if fn.get("name"):
706
 
707
+ tool_state[idx]["name"] += fn["name"]
 
 
708
 
709
+ args = fn.get("arguments", "")
 
 
 
710
 
711
  if args:
712
 
713
+ tool_state[idx]["args"] += args
 
 
714
 
715
+ block_index = tool_block_idx[idx]
 
 
716
 
717
  delta_payload = {
 
718
  "type": "content_block_delta",
 
719
  "index": block_index,
 
720
  "delta": {
 
721
  "type": "input_json_delta",
 
722
  "partial_json": args
723
  }
724
  }
 
736
  error
737
  )
738
 
739
+ # ---------------------------------------------------------
740
+ # CLOSE BLOCKS
741
+ # ---------------------------------------------------------
742
 
743
  if text_block_idx is not None:
744
 
745
  close_payload = {
 
746
  "type": "content_block_stop",
 
747
  "index": text_block_idx
748
  }
749
 
 
753
  "\n\n"
754
  )
755
 
 
 
 
 
756
  for _, block_index in tool_block_idx.items():
757
 
758
  close_payload = {
 
759
  "type": "content_block_stop",
 
760
  "index": block_index
761
  }
762
 
 
766
  "\n\n"
767
  )
768
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  delta_payload = {
 
770
  "type": "message_delta",
 
771
  "delta": {
 
772
  "stop_reason": stop_reason,
 
773
  "stop_sequence": None
774
  },
 
775
  "usage": {
 
776
  "output_tokens": output_tokens
777
  }
778
  }
 
783
  "\n\n"
784
  )
785
 
 
 
 
 
786
  stop_payload = {
 
787
  "type": "message_stop"
788
  }
789
 
 
806
 
807
  request_id = uuid.uuid4().hex[:8]
808
 
 
 
 
 
809
  if not OPENCODE_API_KEY:
810
 
811
  return JSONResponse(
 
812
  status_code=401,
 
813
  content={
814
+ "error": "Missing OPENCODE_API_KEY"
 
 
815
  }
816
  )
817
 
 
 
 
 
818
  try:
819
 
820
  body = await request.json()
 
822
  except Exception:
823
 
824
  return JSONResponse(
 
825
  status_code=400,
 
826
  content={
827
  "error": "Invalid JSON"
828
  }
829
  )
830
 
 
 
 
 
831
  requested_model = body.get(
832
  "model",
833
  "claude-sonnet-4-6"
 
842
  body.get("stream", False)
843
  )
844
 
845
+ # ---------------------------------------------------------
846
  # TOOLS
847
+ # ---------------------------------------------------------
848
 
849
+ anthropic_tools = body.get("tools", [])
 
 
 
850
 
851
  openai_tools = convert_tools_to_openai(
852
  anthropic_tools
 
857
  bool(openai_tools)
858
  )
859
 
860
+ # ---------------------------------------------------------
861
  # MESSAGES
862
+ # ---------------------------------------------------------
863
 
864
  openai_messages = normalize_messages(
865
  body.get("messages", [])
866
  )
867
 
868
+ system_prompt = body.get("system", "")
 
 
 
869
 
870
  if system_prompt:
871
 
 
877
  }
878
  )
879
 
880
+ # ---------------------------------------------------------
881
  # PAYLOAD
882
+ # ---------------------------------------------------------
883
 
884
  payload = build_payload(
 
885
  body,
 
886
  openai_messages,
 
887
  openai_tools,
 
888
  openai_tool_choice,
 
889
  mapped_model,
 
890
  stream
891
  )
892
 
893
  headers = {
 
894
  **UPSTREAM_HEADERS,
 
895
  "x-api-key": OPENCODE_API_KEY
896
  }
897
 
898
  logger.info(
 
899
  "[%s] %s -> %s | stream=%s | tools=%d",
 
900
  request_id,
 
901
  requested_model,
 
902
  mapped_model,
 
903
  stream,
 
904
  len(openai_tools)
905
  )
906
 
 
908
 
909
  last_error = None
910
 
911
+ # =========================================================
912
  # RETRIES
913
+ # =========================================================
914
 
915
+ for attempt in range(1, MAX_RETRIES + 1):
 
 
 
916
 
917
  try:
918
 
919
+ # -------------------------------------------------
920
  # STREAM MODE
921
+ # -------------------------------------------------
922
 
923
  if stream:
924
 
925
+ upstream_request = client.build_request(
926
+ "POST",
927
+ UPSTREAM_URL,
928
+ json=payload,
929
+ headers=headers
 
 
930
  )
931
 
932
  response = await client.send(
 
934
  stream=True
935
  )
936
 
 
 
 
 
937
  if response.status_code != 200:
938
 
939
  raw_error = (
 
941
  ).decode(errors="ignore")
942
 
943
  logger.warning(
 
944
  "[%s] fail %d: %s",
 
945
  request_id,
 
946
  response.status_code,
 
947
  raw_error[:200]
948
  )
949
 
950
  last_error = raw_error
951
 
952
+ await asyncio.sleep(RETRY_DELAY)
 
 
953
 
954
  continue
955
 
 
 
 
 
956
  return StreamingResponse(
 
957
  convert_stream_to_anthropic(
958
  response,
959
  requested_model
960
  ),
 
961
  media_type="text/event-stream",
 
962
  headers=STREAM_RESPONSE_HEADERS,
963
  )
964
 
965
+ # -------------------------------------------------
966
  # NORMAL MODE
967
+ # -------------------------------------------------
968
 
969
  response = await client.post(
 
970
  UPSTREAM_URL,
 
971
  json=payload,
 
972
  headers=headers
973
  )
974
 
 
 
 
 
975
  if response.status_code != 200:
976
 
977
  logger.warning(
 
978
  "[%s] fail %d",
 
979
  request_id,
 
980
  response.status_code
981
  )
982
 
983
  last_error = response.text
984
 
985
+ await asyncio.sleep(RETRY_DELAY)
 
 
986
 
987
  continue
988
 
989
+ raw_response = response.text.strip()
 
 
990
 
991
  if not raw_response:
992
 
993
  return JSONResponse(
 
994
  status_code=502,
 
995
  content={
996
+ "error": "Empty upstream response"
 
 
997
  }
998
  )
999
 
1000
  try:
1001
 
1002
+ parsed_response = response.json()
 
 
1003
 
1004
  except Exception:
1005
 
1006
  return JSONResponse(
 
1007
  status_code=502,
 
1008
  content={
1009
+ "error": "Invalid upstream JSON"
 
 
1010
  }
1011
  )
1012
 
1013
  return JSONResponse(
 
1014
  content=convert_to_anthropic(
1015
  parsed_response,
1016
  requested_model
 
1022
  last_error = str(error)
1023
 
1024
  logger.exception(
 
1025
  "[%s] exception",
 
1026
  request_id
1027
  )
1028
 
1029
+ await asyncio.sleep(RETRY_DELAY)
 
 
 
 
 
 
1030
 
1031
  return JSONResponse(
 
1032
  status_code=502,
 
1033
  content={
 
1034
  "error": "Upstream failed",
 
1035
  "details": last_error
1036
  }
1037
  )