bahi-bh commited on
Commit
f4845df
·
verified ·
1 Parent(s): e601173

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +551 -109
app.py CHANGED
@@ -136,6 +136,9 @@ async def get_models():
136
  # =========================================================
137
  # FIX TOOL SCHEMA
138
  # =========================================================
 
 
 
139
 
140
  def _fix_schema(schema):
141
  """Ensure schema has type=object at root (DeepSeek requirement)."""
@@ -150,94 +153,162 @@ def _fix_schema(schema):
150
  # =========================================================
151
  # NORMALIZE MESSAGES
152
  # =========================================================
 
 
153
 
154
  def normalize_messages(messages):
155
 
156
  output = []
157
 
158
  for msg in messages:
 
159
  role = msg.get("role", "user")
160
  content = msg.get("content", "")
161
-
162
- # الاحتفاظ بـ reasoning_content إذا كان موجوداً في الرسالة الأصلية القادمة من الـ History
163
- reasoning = msg.get("reasoning_content")
164
 
165
  # -------------------------------------------------
166
  # STRING CONTENT
167
  # -------------------------------------------------
 
168
  if isinstance(content, str):
169
- base_msg = {"role": role, "content": content}
170
- if reasoning:
171
- base_msg["reasoning_content"] = reasoning
172
- output.append(base_msg)
 
 
 
173
  continue
174
 
175
  # -------------------------------------------------
176
  # LIST CONTENT
177
  # -------------------------------------------------
 
178
  if isinstance(content, list):
 
179
  text_parts = []
180
  tool_calls = []
181
  tool_results = []
182
 
183
  for item in content:
 
184
  if not isinstance(item, dict):
185
  text_parts.append(str(item))
186
  continue
187
 
188
  item_type = item.get("type", "")
189
 
 
 
 
 
190
  if item_type == "text":
 
191
  txt = item.get("text", "")
 
192
  if txt:
193
  text_parts.append(txt)
194
 
 
 
 
 
195
  elif item_type == "tool_use":
 
196
  tool_calls.append({
197
- "id": item.get("id", f"call_{uuid.uuid4().hex[:8]}"),
 
 
 
198
  "type": "function",
199
  "function": {
200
  "name": item.get("name", ""),
201
- "arguments": json.dumps(item.get("input", {})),
 
 
202
  },
203
  })
204
 
 
 
 
 
205
  elif item_type == "tool_result":
 
206
  rc = item.get("content", "")
 
207
  if isinstance(rc, list):
 
208
  rc = "\n".join(
209
- c.get("text", "") if isinstance(c, dict) else str(c)
 
 
210
  for c in rc
211
  )
 
212
  tool_results.append({
213
  "role": "tool",
214
- "tool_call_id": item.get("tool_use_id", ""),
 
 
 
215
  "content": str(rc),
216
  })
217
 
 
 
 
 
218
  if tool_calls:
 
219
  assistant_msg = {
220
  "role": "assistant",
221
  "tool_calls": tool_calls,
222
- "content": "\n".join(text_parts) if text_parts else None
 
 
 
 
223
  }
224
- if reasoning:
225
- assistant_msg["reasoning_content"] = reasoning
 
226
  output.append(assistant_msg)
 
227
  elif tool_results:
 
228
  output.extend(tool_results)
 
229
  else:
230
- final_msg = {"role": role, "content": "\n".join(text_parts)}
231
- if reasoning:
232
- final_msg["reasoning_content"] = reasoning
233
- output.append(final_msg)
 
 
 
 
 
234
  continue
235
 
236
- fallback_msg = {"role": role, "content": str(content)}
237
- if reasoning:
238
- fallback_msg["reasoning_content"] = reasoning
239
- output.append(fallback_msg)
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  return output
242
 
243
  # =========================================================
@@ -245,11 +316,16 @@ def normalize_messages(messages):
245
  # =========================================================
246
 
247
  def convert_tools_to_openai(tools):
 
248
  if not tools:
249
  return []
 
250
  result = []
 
251
  for tool in tools:
 
252
  schema = _fix_schema(tool.get("input_schema", {}))
 
253
  result.append({
254
  "type": "function",
255
  "function": {
@@ -258,6 +334,7 @@ def convert_tools_to_openai(tools):
258
  "parameters": schema,
259
  },
260
  })
 
261
  return result
262
 
263
  # =========================================================
@@ -267,38 +344,61 @@ def convert_tools_to_openai(tools):
267
  _TOOL_CHOICE_ANY = "required"
268
 
269
  def map_tool_choice(tool_choice_raw, has_tools):
 
270
  if not tool_choice_raw or not has_tools:
271
  return None
 
272
  if isinstance(tool_choice_raw, dict):
 
273
  tool_type = tool_choice_raw.get("type", "auto")
 
274
  if tool_type == "any":
275
  return _TOOL_CHOICE_ANY
 
276
  if tool_type == "tool":
 
277
  return {
278
  "type": "function",
279
  "function": {
280
  "name": tool_choice_raw.get("name", "")
281
  }
282
  }
 
283
  return tool_type
 
284
  return tool_choice_raw
285
 
286
  # =========================================================
287
  # BUILD PAYLOAD
288
  # =========================================================
289
 
290
- def build_payload(body, openai_messages, openai_tools, openai_tool_choice, mapped_model, stream):
 
 
 
 
 
 
 
 
291
  payload = {
292
  "model": mapped_model,
293
  "messages": openai_messages,
294
  "temperature": body.get("temperature", 0.7),
295
- "max_tokens": min(body.get("max_tokens", 32000), 64000),
 
 
 
296
  "stream": stream,
297
  }
 
298
  if openai_tools:
 
299
  payload["tools"] = openai_tools
 
300
  if openai_tool_choice is not None:
301
  payload["tool_choice"] = openai_tool_choice
 
302
  return payload
303
 
304
  # =========================================================
@@ -311,44 +411,80 @@ _FINISH_MAP = {
311
  }
312
 
313
  def convert_to_anthropic(parsed, requested_model):
 
314
  content_blocks = []
 
315
  stop_reason = "end_turn"
 
 
316
  try:
 
317
  choices = parsed.get("choices")
 
318
  if choices:
 
319
  choice = choices[0]
 
320
  message = choice.get("message", {})
321
- stop_reason = _FINISH_MAP.get(choice.get("finish_reason"), "end_turn")
 
 
 
 
 
 
 
 
322
  text = message.get("content") or ""
 
323
  if text:
 
324
  content_blocks.append({
325
  "type": "text",
326
  "text": text
327
  })
 
328
  for tc in (message.get("tool_calls") or []):
 
329
  fn = tc.get("function", {})
 
330
  raw_args = fn.get("arguments", "{}")
 
331
  try:
332
  parsed_args = json.loads(raw_args)
 
333
  except Exception:
334
- parsed_args = {"raw": raw_args}
 
 
 
335
  content_blocks.append({
336
  "type": "tool_use",
337
- "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
 
 
 
338
  "name": fn.get("name", ""),
339
  "input": parsed_args,
340
  })
 
341
  except Exception as error:
342
- logger.warning("convert error: %s", error)
 
 
 
 
343
 
344
  if not content_blocks:
 
345
  content_blocks.append({
346
  "type": "text",
347
  "text": ""
348
  })
349
 
350
  usage = parsed.get("usage", {})
351
- return {
 
352
  "id": f"msg_{uuid.uuid4().hex}",
353
  "type": "message",
354
  "role": "assistant",
@@ -357,10 +493,22 @@ def convert_to_anthropic(parsed, requested_model):
357
  "stop_reason": stop_reason,
358
  "stop_sequence": None,
359
  "usage": {
360
- "input_tokens": usage.get("prompt_tokens", 0),
361
- "output_tokens": usage.get("completion_tokens", 0),
 
 
 
 
 
 
362
  },
363
  }
 
 
 
 
 
 
364
 
365
  # =========================================================
366
  # STREAM CONVERSION
@@ -373,10 +521,16 @@ _STREAM_BLOCK_STOP = "event: content_block_stop\ndata: "
373
  _STREAM_MSG_DELTA = "event: message_delta\ndata: "
374
  _STREAM_MSG_STOP = "event: message_stop\ndata: "
375
 
376
- async def convert_stream_to_anthropic(response, requested_model):
 
 
 
 
377
  msg_id = f"msg_{uuid.uuid4().hex}"
 
378
  input_tokens = 0
379
  output_tokens = 0
 
380
 
381
  start_payload = {
382
  "type": "message_start",
@@ -395,86 +549,154 @@ async def convert_stream_to_anthropic(response, requested_model):
395
  },
396
  }
397
 
398
- yield f"{_STREAM_MSG_START}{json.dumps(start_payload)}\n\n"
 
 
 
 
 
399
  yield 'event: ping\ndata: {"type":"ping"}\n\n'
400
 
401
  tool_state = {}
402
  tool_block_idx = {}
 
403
  text_block_idx = None
 
404
  next_idx = 0
 
405
  stop_reason = "end_turn"
406
 
407
  try:
 
408
  async for raw in response.aiter_lines():
 
409
  if not raw or not raw.startswith("data:"):
410
  continue
411
 
412
  line = raw[5:].strip()
 
413
  if line == "[DONE]":
414
  break
415
 
416
  try:
417
  chunk = json.loads(line)
 
418
  except Exception:
419
  continue
420
 
421
  if chunk.get("usage"):
 
422
  usage = chunk["usage"]
423
- input_tokens = usage.get("prompt_tokens", input_tokens)
424
- output_tokens = usage.get("completion_tokens", output_tokens)
 
 
 
 
 
 
 
 
425
 
426
  choices = chunk.get("choices")
 
427
  if not choices:
428
  continue
429
 
430
  choice = choices[0]
431
- delta = choice.get("delta", {})
432
 
433
- # تخطي حقول الـ reasoning_content حتى لا نرسلها لمنصة كلود كورك كـ نص
434
- if "reasoning_content" in delta and not delta.get("content"):
435
- continue
436
 
437
  finish_reason = choice.get("finish_reason")
 
438
  if finish_reason == "tool_calls":
439
  stop_reason = "tool_use"
 
440
  elif finish_reason == "length":
441
  stop_reason = "max_tokens"
 
 
 
 
 
 
 
 
 
442
 
443
  text_delta = delta.get("content")
 
444
  if text_delta:
 
445
  if text_block_idx is None:
 
446
  text_block_idx = next_idx
447
  next_idx += 1
 
448
  block_payload = {
449
  "type": "content_block_start",
450
  "index": text_block_idx,
451
- "content_block": {"type": "text", "text": ""}
 
 
 
452
  }
453
- yield f"{_STREAM_BLOCK_START}{json.dumps(block_payload)}\n\n"
 
 
 
 
 
454
 
455
  delta_payload = {
456
  "type": "content_block_delta",
457
  "index": text_block_idx,
458
- "delta": {"type": "text_delta", "text": text_delta}
 
 
 
459
  }
460
- yield f"{_STREAM_BLOCK_DELTA}{json.dumps(delta_payload)}\n\n"
 
 
 
 
 
 
 
 
 
461
 
462
  tool_call_deltas = delta.get("tool_calls")
 
463
  if tool_call_deltas:
 
464
  for tc_delta in tool_call_deltas:
 
465
  idx = tc_delta.get("index", 0)
 
466
  if idx not in tool_state:
 
467
  tool_state[idx] = {
468
- "id": tc_delta.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
 
 
 
469
  "name": "",
470
  "args": "",
471
  }
 
472
  block_index = next_idx
473
  next_idx += 1
 
474
  tool_block_idx[idx] = block_index
475
 
476
  fn = tc_delta.get("function", {})
477
- tool_state[idx]["name"] = fn.get("name", "")
 
 
 
 
478
 
479
  block_payload = {
480
  "type": "content_block_start",
@@ -486,75 +708,197 @@ async def convert_stream_to_anthropic(response, requested_model):
486
  "input": {}
487
  }
488
  }
489
- yield f"{_STREAM_BLOCK_START}{json.dumps(block_payload)}\n\n"
 
 
 
 
 
490
 
491
  fn = tc_delta.get("function", {})
 
492
  if fn.get("name"):
 
493
  tool_state[idx]["name"] += fn["name"]
494
 
495
  args = fn.get("arguments", "")
 
496
  if args:
 
497
  tool_state[idx]["args"] += args
 
498
  block_index = tool_block_idx[idx]
 
499
  delta_payload = {
500
  "type": "content_block_delta",
501
  "index": block_index,
502
- "delta": {"type": "input_json_delta", "partial_json": args}
 
 
 
503
  }
504
- yield f"{_STREAM_BLOCK_DELTA}{json.dumps(delta_payload)}\n\n"
 
 
 
 
 
505
 
506
  except Exception as error:
507
- logger.warning("stream error: %s", error)
 
 
 
 
 
 
 
 
508
 
509
  if text_block_idx is not None:
510
- close_payload = {"type": "content_block_stop", "index": text_block_idx}
511
- yield f"{_STREAM_BLOCK_STOP}{json.dumps(close_payload)}\n\n"
 
 
 
 
 
 
 
 
 
512
 
513
  for _, block_index in tool_block_idx.items():
514
- close_payload = {"type": "content_block_stop", "index": block_index}
515
- yield f"{_STREAM_BLOCK_STOP}{json.dumps(close_payload)}\n\n"
516
 
517
- stop_payload = {
 
 
 
 
 
 
 
 
 
 
 
518
  "type": "message_delta",
519
- "delta": {"stop_reason": stop_reason, "stop_sequence": None},
520
- "usage": {"output_tokens": output_tokens}
 
 
 
 
 
521
  }
522
- yield f"{_STREAM_MSG_DELTA}{json.dumps(stop_payload)}\n\n"
 
 
 
 
 
 
 
 
 
523
 
524
- final_payload = {"type": "message_stop"}
525
- yield f"{_STREAM_MSG_STOP}{json.dumps(final_payload)}\n\n"
 
 
 
 
 
 
 
526
 
527
  # =========================================================
528
- # CHAT COMPLETIONS ENDPOINT
529
  # =========================================================
530
 
531
  @app.post("/v1/messages")
532
- async def chat_completions(
 
533
  request: Request,
534
- authorization: str = Header(None)
535
  ):
 
 
 
 
 
 
 
 
 
 
 
 
536
  try:
 
537
  body = await request.json()
 
538
  except Exception:
539
- return JSONResponse(status_code=400, content={"error": "Invalid JSON body"})
540
 
541
- requested_model = body.get("model", "claude-sonnet-4-6")
542
- mapped_model = MODEL_MAP.get(requested_model, DEFAULT_MODEL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
- raw_messages = body.get("messages", [])
545
  system_prompt = body.get("system", "")
546
 
547
- openai_messages = normalize_messages(raw_messages)
548
  if system_prompt:
549
- openai_messages.insert(0, {"role": "system", "content": system_prompt})
550
 
551
- raw_tools = body.get("tools", [])
552
- openai_tools = convert_tools_to_openai(raw_tools)
553
-
554
- raw_tool_choice = body.get("tool_choice")
555
- openai_tool_choice = map_tool_choice(raw_tool_choice, bool(openai_tools))
 
 
556
 
557
- stream = body.get("stream", False)
 
 
558
 
559
  payload = build_payload(
560
  body,
@@ -565,50 +909,148 @@ async def chat_completions(
565
  stream
566
  )
567
 
568
- headers = dict(UPSTREAM_HEADERS)
569
- if OPENCODE_API_KEY:
570
- headers["Authorization"] = f"Bearer {OPENCODE_API_KEY}"
571
- elif authorization:
572
- headers["Authorization"] = authorization
 
 
 
 
 
 
 
 
573
 
574
  client = request.app.state.client
575
 
576
- for attempt in range(MAX_RETRIES + 1):
 
 
 
 
 
 
 
577
  try:
 
 
 
 
 
578
  if stream:
579
- req = client.build_request("POST", UPSTREAM_URL, json=payload, headers=headers)
580
- response = await client.send(req, stream=True)
581
-
 
 
 
 
 
 
 
 
 
 
582
  if response.status_code != 200:
583
- await response.aread()
584
- logger.warning(f"fail {response.status_code}: {response.text}")
585
- if attempt < MAX_RETRIES:
586
- await asyncio.sleep(RETRY_DELAY)
587
- continue
588
- return JSONResponse(status_code=502, content={"error": f"Upstream error {response.status_code}"})
589
-
 
 
 
 
 
 
 
 
 
 
 
590
  return StreamingResponse(
591
- convert_stream_to_anthropic(response, requested_model),
 
 
 
592
  media_type="text/event-stream",
593
- headers=STREAM_RESPONSE_HEADERS
594
  )
595
- else:
596
- response = await client.post(UPSTREAM_URL, json=payload, headers=headers)
597
-
598
- if response.status_code != 200:
599
- logger.warning(f"fail {response.status_code}: {response.text}")
600
- if attempt < MAX_RETRIES:
601
- await asyncio.sleep(RETRY_DELAY)
602
- continue
603
- return JSONResponse(status_code=502, content={"error": f"Upstream error {response.status_code}"})
604
-
605
- parsed = response.json()
606
- anthropic_response = convert_to_anthropic(parsed, requested_model)
607
- return JSONResponse(content=anthropic_response)
608
 
609
- except Exception as e:
610
- logger.error(f"Request exception: {str(e)}")
611
- if attempt < MAX_RETRIES:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  await asyncio.sleep(RETRY_DELAY)
 
613
  continue
614
- return JSONResponse(status_code=502, content={"error": "Gateway timeout/connection issue"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)."""
 
153
  # =========================================================
154
  # NORMALIZE MESSAGES
155
  # =========================================================
156
+ # DeepSeek requires `reasoning_content` to be passed back in the
157
+ # message history if it was generated in a previous turn.
158
 
159
  def normalize_messages(messages):
160
 
161
  output = []
162
 
163
  for msg in messages:
164
+
165
  role = msg.get("role", "user")
166
  content = msg.get("content", "")
167
+ reasoning_content = msg.get("reasoning_content")
 
 
168
 
169
  # -------------------------------------------------
170
  # STRING CONTENT
171
  # -------------------------------------------------
172
+
173
  if isinstance(content, str):
174
+ new_msg = {
175
+ "role": role,
176
+ "content": content
177
+ }
178
+ if role == "assistant" and reasoning_content:
179
+ new_msg["reasoning_content"] = reasoning_content
180
+ output.append(new_msg)
181
  continue
182
 
183
  # -------------------------------------------------
184
  # LIST CONTENT
185
  # -------------------------------------------------
186
+
187
  if isinstance(content, list):
188
+
189
  text_parts = []
190
  tool_calls = []
191
  tool_results = []
192
 
193
  for item in content:
194
+
195
  if not isinstance(item, dict):
196
  text_parts.append(str(item))
197
  continue
198
 
199
  item_type = item.get("type", "")
200
 
201
+ # -----------------------------------------
202
+ # TEXT
203
+ # -----------------------------------------
204
+
205
  if item_type == "text":
206
+
207
  txt = item.get("text", "")
208
+
209
  if txt:
210
  text_parts.append(txt)
211
 
212
+ # -----------------------------------------
213
+ # TOOL USE
214
+ # -----------------------------------------
215
+
216
  elif item_type == "tool_use":
217
+
218
  tool_calls.append({
219
+ "id": item.get(
220
+ "id",
221
+ f"call_{uuid.uuid4().hex[:8]}"
222
+ ),
223
  "type": "function",
224
  "function": {
225
  "name": item.get("name", ""),
226
+ "arguments": json.dumps(
227
+ item.get("input", {})
228
+ ),
229
  },
230
  })
231
 
232
+ # -----------------------------------------
233
+ # TOOL RESULT
234
+ # -----------------------------------------
235
+
236
  elif item_type == "tool_result":
237
+
238
  rc = item.get("content", "")
239
+
240
  if isinstance(rc, list):
241
+
242
  rc = "\n".join(
243
+ c.get("text", "")
244
+ if isinstance(c, dict)
245
+ else str(c)
246
  for c in rc
247
  )
248
+
249
  tool_results.append({
250
  "role": "tool",
251
+ "tool_call_id": item.get(
252
+ "tool_use_id",
253
+ ""
254
+ ),
255
  "content": str(rc),
256
  })
257
 
258
+ # ---------------------------------------------
259
+ # FINALIZE MESSAGE
260
+ # ---------------------------------------------
261
+
262
  if tool_calls:
263
+
264
  assistant_msg = {
265
  "role": "assistant",
266
  "tool_calls": tool_calls,
267
+ "content": (
268
+ "\n".join(text_parts)
269
+ if text_parts
270
+ else None
271
+ )
272
  }
273
+ if reasoning_content:
274
+ assistant_msg["reasoning_content"] = reasoning_content
275
+
276
  output.append(assistant_msg)
277
+
278
  elif tool_results:
279
+
280
  output.extend(tool_results)
281
+
282
  else:
283
+
284
+ new_msg = {
285
+ "role": role,
286
+ "content": "\n".join(text_parts)
287
+ }
288
+ if role == "assistant" and reasoning_content:
289
+ new_msg["reasoning_content"] = reasoning_content
290
+ output.append(new_msg)
291
+
292
  continue
293
 
294
+ # -------------------------------------------------
295
+ # FALLBACK
296
+ # -------------------------------------------------
 
297
 
298
+ new_msg = {
299
+ "role": role,
300
+ "content": str(content)
301
+ }
302
+ if role == "assistant" and reasoning_content:
303
+ new_msg["reasoning_content"] = reasoning_content
304
+ output.append(new_msg)
305
+
306
+ # -----------------------------------------------------
307
+ # NO LONGER STRIP reasoning_content
308
+ # -----------------------------------------------------
309
+ # DeepSeek requires reasoning_content to be passed back
310
+ # in the message history when using thinking mode.
311
+
312
  return output
313
 
314
  # =========================================================
 
316
  # =========================================================
317
 
318
  def convert_tools_to_openai(tools):
319
+
320
  if not tools:
321
  return []
322
+
323
  result = []
324
+
325
  for tool in tools:
326
+
327
  schema = _fix_schema(tool.get("input_schema", {}))
328
+
329
  result.append({
330
  "type": "function",
331
  "function": {
 
334
  "parameters": schema,
335
  },
336
  })
337
+
338
  return result
339
 
340
  # =========================================================
 
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
+
367
  return tool_type
368
+
369
  return tool_choice_raw
370
 
371
  # =========================================================
372
  # BUILD PAYLOAD
373
  # =========================================================
374
 
375
+ def build_payload(
376
+ body,
377
+ openai_messages,
378
+ openai_tools,
379
+ openai_tool_choice,
380
+ mapped_model,
381
+ stream
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
+
395
  if openai_tools:
396
+
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
  # =========================================================
 
411
  }
412
 
413
  def convert_to_anthropic(parsed, requested_model):
414
+
415
  content_blocks = []
416
+
417
  stop_reason = "end_turn"
418
+ reasoning_content = None
419
+
420
  try:
421
+
422
  choices = parsed.get("choices")
423
+
424
  if choices:
425
+
426
  choice = choices[0]
427
+
428
  message = choice.get("message", {})
429
+
430
+ # Capture reasoning_content from DeepSeek response
431
+ reasoning_content = message.get("reasoning_content")
432
+
433
+ stop_reason = _FINISH_MAP.get(
434
+ choice.get("finish_reason"),
435
+ "end_turn"
436
+ )
437
+
438
  text = message.get("content") or ""
439
+
440
  if text:
441
+
442
  content_blocks.append({
443
  "type": "text",
444
  "text": text
445
  })
446
+
447
  for tc in (message.get("tool_calls") or []):
448
+
449
  fn = tc.get("function", {})
450
+
451
  raw_args = fn.get("arguments", "{}")
452
+
453
  try:
454
  parsed_args = json.loads(raw_args)
455
+
456
  except Exception:
457
+ parsed_args = {
458
+ "raw": raw_args
459
+ }
460
+
461
  content_blocks.append({
462
  "type": "tool_use",
463
+ "id": tc.get(
464
+ "id",
465
+ f"toolu_{uuid.uuid4().hex[:8]}"
466
+ ),
467
  "name": fn.get("name", ""),
468
  "input": parsed_args,
469
  })
470
+
471
  except Exception as error:
472
+
473
+ logger.warning(
474
+ "convert error: %s",
475
+ error
476
+ )
477
 
478
  if not content_blocks:
479
+
480
  content_blocks.append({
481
  "type": "text",
482
  "text": ""
483
  })
484
 
485
  usage = parsed.get("usage", {})
486
+
487
+ response_obj = {
488
  "id": f"msg_{uuid.uuid4().hex}",
489
  "type": "message",
490
  "role": "assistant",
 
493
  "stop_reason": stop_reason,
494
  "stop_sequence": None,
495
  "usage": {
496
+ "input_tokens": usage.get(
497
+ "prompt_tokens",
498
+ 0
499
+ ),
500
+ "output_tokens": usage.get(
501
+ "completion_tokens",
502
+ 0
503
+ ),
504
  },
505
  }
506
+
507
+ # Pass reasoning_content back to the client (Cline/Cursor)
508
+ if reasoning_content:
509
+ response_obj["reasoning_content"] = reasoning_content
510
+
511
+ return response_obj
512
 
513
  # =========================================================
514
  # STREAM CONVERSION
 
521
  _STREAM_MSG_DELTA = "event: message_delta\ndata: "
522
  _STREAM_MSG_STOP = "event: message_stop\ndata: "
523
 
524
+ async def convert_stream_to_anthropic(
525
+ response,
526
+ requested_model
527
+ ):
528
+
529
  msg_id = f"msg_{uuid.uuid4().hex}"
530
+
531
  input_tokens = 0
532
  output_tokens = 0
533
+ reasoning_content = ""
534
 
535
  start_payload = {
536
  "type": "message_start",
 
549
  },
550
  }
551
 
552
+ yield (
553
+ f"{_STREAM_MSG_START}"
554
+ f"{json.dumps(start_payload)}"
555
+ "\n\n"
556
+ )
557
+
558
  yield 'event: ping\ndata: {"type":"ping"}\n\n'
559
 
560
  tool_state = {}
561
  tool_block_idx = {}
562
+
563
  text_block_idx = None
564
+
565
  next_idx = 0
566
+
567
  stop_reason = "end_turn"
568
 
569
  try:
570
+
571
  async for raw in response.aiter_lines():
572
+
573
  if not raw or not raw.startswith("data:"):
574
  continue
575
 
576
  line = raw[5:].strip()
577
+
578
  if line == "[DONE]":
579
  break
580
 
581
  try:
582
  chunk = json.loads(line)
583
+
584
  except Exception:
585
  continue
586
 
587
  if chunk.get("usage"):
588
+
589
  usage = chunk["usage"]
590
+
591
+ input_tokens = usage.get(
592
+ "prompt_tokens",
593
+ input_tokens
594
+ )
595
+
596
+ output_tokens = usage.get(
597
+ "completion_tokens",
598
+ output_tokens
599
+ )
600
 
601
  choices = chunk.get("choices")
602
+
603
  if not choices:
604
  continue
605
 
606
  choice = choices[0]
 
607
 
608
+ delta = choice.get("delta", {})
 
 
609
 
610
  finish_reason = choice.get("finish_reason")
611
+
612
  if finish_reason == "tool_calls":
613
  stop_reason = "tool_use"
614
+
615
  elif finish_reason == "length":
616
  stop_reason = "max_tokens"
617
+
618
+ # Collect reasoning_content from stream chunks
619
+ reasoning_delta = delta.get("reasoning_content")
620
+ if reasoning_delta:
621
+ reasoning_content += reasoning_delta
622
+
623
+ # -------------------------------------------------
624
+ # TEXT DELTA
625
+ # -------------------------------------------------
626
 
627
  text_delta = delta.get("content")
628
+
629
  if text_delta:
630
+
631
  if text_block_idx is None:
632
+
633
  text_block_idx = next_idx
634
  next_idx += 1
635
+
636
  block_payload = {
637
  "type": "content_block_start",
638
  "index": text_block_idx,
639
+ "content_block": {
640
+ "type": "text",
641
+ "text": ""
642
+ }
643
  }
644
+
645
+ yield (
646
+ f"{_STREAM_BLOCK_START}"
647
+ f"{json.dumps(block_payload)}"
648
+ "\n\n"
649
+ )
650
 
651
  delta_payload = {
652
  "type": "content_block_delta",
653
  "index": text_block_idx,
654
+ "delta": {
655
+ "type": "text_delta",
656
+ "text": text_delta
657
+ }
658
  }
659
+
660
+ yield (
661
+ f"{_STREAM_BLOCK_DELTA}"
662
+ f"{json.dumps(delta_payload)}"
663
+ "\n\n"
664
+ )
665
+
666
+ # -------------------------------------------------
667
+ # TOOL CALLS
668
+ # -------------------------------------------------
669
 
670
  tool_call_deltas = delta.get("tool_calls")
671
+
672
  if tool_call_deltas:
673
+
674
  for tc_delta in tool_call_deltas:
675
+
676
  idx = tc_delta.get("index", 0)
677
+
678
  if idx not in tool_state:
679
+
680
  tool_state[idx] = {
681
+ "id": tc_delta.get(
682
+ "id",
683
+ f"toolu_{uuid.uuid4().hex[:8]}"
684
+ ),
685
  "name": "",
686
  "args": "",
687
  }
688
+
689
  block_index = next_idx
690
  next_idx += 1
691
+
692
  tool_block_idx[idx] = block_index
693
 
694
  fn = tc_delta.get("function", {})
695
+
696
+ tool_state[idx]["name"] = fn.get(
697
+ "name",
698
+ ""
699
+ )
700
 
701
  block_payload = {
702
  "type": "content_block_start",
 
708
  "input": {}
709
  }
710
  }
711
+
712
+ yield (
713
+ f"{_STREAM_BLOCK_START}"
714
+ f"{json.dumps(block_payload)}"
715
+ "\n\n"
716
+ )
717
 
718
  fn = tc_delta.get("function", {})
719
+
720
  if fn.get("name"):
721
+
722
  tool_state[idx]["name"] += fn["name"]
723
 
724
  args = fn.get("arguments", "")
725
+
726
  if args:
727
+
728
  tool_state[idx]["args"] += args
729
+
730
  block_index = tool_block_idx[idx]
731
+
732
  delta_payload = {
733
  "type": "content_block_delta",
734
  "index": block_index,
735
+ "delta": {
736
+ "type": "input_json_delta",
737
+ "partial_json": args
738
+ }
739
  }
740
+
741
+ yield (
742
+ f"{_STREAM_BLOCK_DELTA}"
743
+ f"{json.dumps(delta_payload)}"
744
+ "\n\n"
745
+ )
746
 
747
  except Exception as error:
748
+
749
+ logger.warning(
750
+ "stream error: %s",
751
+ error
752
+ )
753
+
754
+ # ---------------------------------------------------------
755
+ # CLOSE BLOCKS
756
+ # ---------------------------------------------------------
757
 
758
  if text_block_idx is not None:
759
+
760
+ close_payload = {
761
+ "type": "content_block_stop",
762
+ "index": text_block_idx
763
+ }
764
+
765
+ yield (
766
+ f"{_STREAM_BLOCK_STOP}"
767
+ f"{json.dumps(close_payload)}"
768
+ "\n\n"
769
+ )
770
 
771
  for _, block_index in tool_block_idx.items():
 
 
772
 
773
+ close_payload = {
774
+ "type": "content_block_stop",
775
+ "index": block_index
776
+ }
777
+
778
+ yield (
779
+ f"{_STREAM_BLOCK_STOP}"
780
+ f"{json.dumps(close_payload)}"
781
+ "\n\n"
782
+ )
783
+
784
+ delta_payload = {
785
  "type": "message_delta",
786
+ "delta": {
787
+ "stop_reason": stop_reason,
788
+ "stop_sequence": None
789
+ },
790
+ "usage": {
791
+ "output_tokens": output_tokens
792
+ }
793
  }
794
+
795
+ # Pass reasoning_content back to the client in stream mode
796
+ if reasoning_content:
797
+ delta_payload["reasoning_content"] = reasoning_content
798
+
799
+ yield (
800
+ f"{_STREAM_MSG_DELTA}"
801
+ f"{json.dumps(delta_payload)}"
802
+ "\n\n"
803
+ )
804
 
805
+ stop_payload = {
806
+ "type": "message_stop"
807
+ }
808
+
809
+ yield (
810
+ f"{_STREAM_MSG_STOP}"
811
+ f"{json.dumps(stop_payload)}"
812
+ "\n\n"
813
+ )
814
 
815
  # =========================================================
816
+ # MAIN ENDPOINT
817
  # =========================================================
818
 
819
  @app.post("/v1/messages")
820
+ @app.post("/v1/chat/completions")
821
+ async def messages(
822
  request: Request,
823
+ authorization: str = Header(default=None)
824
  ):
825
+
826
+ request_id = uuid.uuid4().hex[:8]
827
+
828
+ if not OPENCODE_API_KEY:
829
+
830
+ return JSONResponse(
831
+ status_code=401,
832
+ content={
833
+ "error": "Missing OPENCODE_API_KEY"
834
+ }
835
+ )
836
+
837
  try:
838
+
839
  body = await request.json()
840
+
841
  except Exception:
 
842
 
843
+ return JSONResponse(
844
+ status_code=400,
845
+ content={
846
+ "error": "Invalid JSON"
847
+ }
848
+ )
849
+
850
+ requested_model = body.get(
851
+ "model",
852
+ "claude-sonnet-4-6"
853
+ )
854
+
855
+ mapped_model = MODEL_MAP.get(
856
+ requested_model,
857
+ DEFAULT_MODEL
858
+ )
859
+
860
+ stream = bool(
861
+ body.get("stream", False)
862
+ )
863
+
864
+ # ---------------------------------------------------------
865
+ # TOOLS
866
+ # ---------------------------------------------------------
867
+
868
+ anthropic_tools = body.get("tools", [])
869
+
870
+ openai_tools = convert_tools_to_openai(
871
+ anthropic_tools
872
+ )
873
+
874
+ openai_tool_choice = map_tool_choice(
875
+ body.get("tool_choice"),
876
+ bool(openai_tools)
877
+ )
878
+
879
+ # ---------------------------------------------------------
880
+ # MESSAGES
881
+ # ---------------------------------------------------------
882
+
883
+ openai_messages = normalize_messages(
884
+ body.get("messages", [])
885
+ )
886
 
 
887
  system_prompt = body.get("system", "")
888
 
 
889
  if system_prompt:
 
890
 
891
+ openai_messages.insert(
892
+ 0,
893
+ {
894
+ "role": "system",
895
+ "content": system_prompt
896
+ }
897
+ )
898
 
899
+ # ---------------------------------------------------------
900
+ # PAYLOAD
901
+ # ---------------------------------------------------------
902
 
903
  payload = build_payload(
904
  body,
 
909
  stream
910
  )
911
 
912
+ headers = {
913
+ **UPSTREAM_HEADERS,
914
+ "x-api-key": OPENCODE_API_KEY
915
+ }
916
+
917
+ logger.info(
918
+ "[%s] %s -> %s | stream=%s | tools=%d",
919
+ request_id,
920
+ requested_model,
921
+ mapped_model,
922
+ stream,
923
+ len(openai_tools)
924
+ )
925
 
926
  client = request.app.state.client
927
 
928
+ last_error = None
929
+
930
+ # =========================================================
931
+ # RETRIES
932
+ # =========================================================
933
+
934
+ for attempt in range(1, MAX_RETRIES + 1):
935
+
936
  try:
937
+
938
+ # -------------------------------------------------
939
+ # STREAM MODE
940
+ # -------------------------------------------------
941
+
942
  if stream:
943
+
944
+ upstream_request = client.build_request(
945
+ "POST",
946
+ UPSTREAM_URL,
947
+ json=payload,
948
+ headers=headers
949
+ )
950
+
951
+ response = await client.send(
952
+ upstream_request,
953
+ stream=True
954
+ )
955
+
956
  if response.status_code != 200:
957
+
958
+ raw_error = (
959
+ await response.aread()
960
+ ).decode(errors="ignore")
961
+
962
+ logger.warning(
963
+ "[%s] fail %d: %s",
964
+ request_id,
965
+ response.status_code,
966
+ raw_error[:200]
967
+ )
968
+
969
+ last_error = raw_error
970
+
971
+ await asyncio.sleep(RETRY_DELAY)
972
+
973
+ continue
974
+
975
  return StreamingResponse(
976
+ convert_stream_to_anthropic(
977
+ response,
978
+ requested_model
979
+ ),
980
  media_type="text/event-stream",
981
+ headers=STREAM_RESPONSE_HEADERS,
982
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
983
 
984
+ # -------------------------------------------------
985
+ # NORMAL MODE
986
+ # -------------------------------------------------
987
+
988
+ response = await client.post(
989
+ UPSTREAM_URL,
990
+ json=payload,
991
+ headers=headers
992
+ )
993
+
994
+ if response.status_code != 200:
995
+
996
+ logger.warning(
997
+ "[%s] fail %d",
998
+ request_id,
999
+ response.status_code
1000
+ )
1001
+
1002
+ last_error = response.text
1003
+
1004
  await asyncio.sleep(RETRY_DELAY)
1005
+
1006
  continue
1007
+
1008
+ raw_response = response.text.strip()
1009
+
1010
+ if not raw_response:
1011
+
1012
+ return JSONResponse(
1013
+ status_code=502,
1014
+ content={
1015
+ "error": "Empty upstream response"
1016
+ }
1017
+ )
1018
+
1019
+ try:
1020
+
1021
+ parsed_response = response.json()
1022
+
1023
+ except Exception:
1024
+
1025
+ return JSONResponse(
1026
+ status_code=502,
1027
+ content={
1028
+ "error": "Invalid upstream JSON"
1029
+ }
1030
+ )
1031
+
1032
+ return JSONResponse(
1033
+ content=convert_to_anthropic(
1034
+ parsed_response,
1035
+ requested_model
1036
+ )
1037
+ )
1038
+
1039
+ except Exception as error:
1040
+
1041
+ last_error = str(error)
1042
+
1043
+ logger.exception(
1044
+ "[%s] exception",
1045
+ request_id
1046
+ )
1047
+
1048
+ await asyncio.sleep(RETRY_DELAY)
1049
+
1050
+ return JSONResponse(
1051
+ status_code=502,
1052
+ content={
1053
+ "error": "Upstream failed",
1054
+ "details": last_error
1055
+ }
1056
+ )