Wilker commited on
Commit
ce54b53
·
1 Parent(s): d3880a1

feat: adiciona suporte a tool calling (groq/mistral/openrouter/gemini)

Browse files
Files changed (1) hide show
  1. app.py +145 -16
app.py CHANGED
@@ -411,6 +411,23 @@ logger.info("Construindo índices de conhecimento (RAG) e skills...")
411
  reload_indexes()
412
 
413
  # ── Schemas ────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  class Message(BaseModel):
415
  role: Literal["system", "user", "assistant"]
416
  content: constr(min_length=1, max_length=10000)
@@ -430,6 +447,8 @@ class ChatRequest(BaseModel):
430
  messages: List[Message] = Field(min_length=1, description="Lista de mensagens")
431
  max_tokens: Optional[int] = Field(default=8192, ge=1, le=1000000, description="Máximo de tokens")
432
  temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0, description="Temperatura de geração")
 
 
433
 
434
  @field_validator("model")
435
  @classmethod
@@ -645,6 +664,27 @@ def convert_to_gemini_format(chat_messages, system_content=None):
645
  return payload
646
 
647
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  # ── Helper: Sanitiza mensagens para GGUF ──────────────────────────────────────
649
  def sanitize_chat_for_gguf(chat_messages):
650
  messages = [{"role": m["role"], "content": m["content"]} for m in chat_messages]
@@ -685,6 +725,8 @@ def chat_completions(req: ChatRequest):
685
  t0 = time.time()
686
 
687
  if backend == "transformers":
 
 
688
  tokenizer = state["tokenizer"]
689
  model = state["model"]
690
 
@@ -710,6 +752,8 @@ def chat_completions(req: ChatRequest):
710
  completion_tokens = len(generated)
711
 
712
  elif backend == "gguf":
 
 
713
  llm = state["model"]
714
  safe_chat = sanitize_chat_for_gguf(chat)
715
  result = llm.create_chat_completion(
@@ -723,6 +767,8 @@ def chat_completions(req: ChatRequest):
723
  completion_tokens = usage.get("completion_tokens", 0)
724
 
725
  elif backend == "kilo":
 
 
726
  model_id = state["model"]
727
  kilo_api_key = os.environ.get("KILO_API_KEY")
728
  if not kilo_api_key:
@@ -787,10 +833,11 @@ def chat_completions(req: ChatRequest):
787
  "max_tokens": max_tokens,
788
  "temperature": temperature if temperature else 0.0,
789
  }
 
 
 
790
 
791
- @async_retry_with_backoff(max_retries=3, initial_delay=1, backoff_factor=2)
792
- async def call_openrouter_api():
793
- async with httpx.AsyncClient() as client:
794
  resp = await client.post(
795
  "https://openrouter.ai/api/v1/chat/completions",
796
  json=payload,
@@ -815,7 +862,9 @@ def chat_completions(req: ChatRequest):
815
  if "choices" not in data or not data["choices"]:
816
  raise APIError(f"Resposta inválida da OpenRouter API: {data}")
817
 
818
- text = data["choices"][0]["message"]["content"]
 
 
819
  usage = data.get("usage", {})
820
  prompt_tokens = usage.get("prompt_tokens", 0)
821
  completion_tokens = usage.get("completion_tokens", 0)
@@ -836,6 +885,9 @@ def chat_completions(req: ChatRequest):
836
  "max_tokens": max_tokens,
837
  "temperature": temperature if temperature else 0.0,
838
  }
 
 
 
839
 
840
  payload_bytes = len(json.dumps(payload))
841
  logger.info(f"[GROQ DEBUG] Payload size: {payload_bytes} bytes, Messages: {len(chat)}")
@@ -867,7 +919,9 @@ def chat_completions(req: ChatRequest):
867
  if "choices" not in data or not data["choices"]:
868
  raise APIError(f"Resposta inválida da Groq API: {data}")
869
 
870
- text = data["choices"][0]["message"]["content"]
 
 
871
  usage = data.get("usage", {})
872
  prompt_tokens = usage.get("prompt_tokens", 0)
873
  completion_tokens = usage.get("completion_tokens", 0)
@@ -888,6 +942,9 @@ def chat_completions(req: ChatRequest):
888
  "max_tokens": max_tokens,
889
  "temperature": temperature if temperature else 0.0,
890
  }
 
 
 
891
 
892
  @async_retry_with_backoff(max_retries=3, initial_delay=1, backoff_factor=2)
893
  async def call_mistral_api():
@@ -916,7 +973,9 @@ def chat_completions(req: ChatRequest):
916
  if "choices" not in data or not data["choices"]:
917
  raise APIError(f"Resposta inválida da Mistral API: {data}")
918
 
919
- text = data["choices"][0]["message"]["content"]
 
 
920
  usage = data.get("usage", {})
921
  prompt_tokens = usage.get("prompt_tokens", 0)
922
  completion_tokens = usage.get("completion_tokens", 0)
@@ -935,6 +994,9 @@ def chat_completions(req: ChatRequest):
935
  "temperature": temperature if temperature else 0.0,
936
  }
937
 
 
 
 
938
  if model_id.startswith("gemini"):
939
  gemini_payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
940
 
@@ -969,7 +1031,10 @@ def chat_completions(req: ChatRequest):
969
 
970
  logger.info(f"[GEMINI DEBUG] finishReason={data['candidates'][0].get('finishReason')}, content={data['candidates'][0].get('content', {})}")
971
 
972
- text = data["candidates"][0]["content"]["parts"][0]["text"]
 
 
 
973
  usage = data.get("usageMetadata", {})
974
  prompt_tokens = usage.get("promptTokenCount", 0)
975
  completion_tokens = usage.get("candidatesTokenCount", 0)
@@ -977,13 +1042,31 @@ def chat_completions(req: ChatRequest):
977
  finish_reason = "stop"
978
  if data["candidates"][0].get("finishReason") == "MAX_TOKENS":
979
  finish_reason = "length"
980
-
981
  else:
982
  raise BackendError("Backend inválido")
983
 
984
  elapsed = time.time() - t0
985
  logger.info(f"Resposta gerada em {elapsed:.1f}s ({backend}, web={req.web})")
986
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
987
  return {
988
  "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
989
  "object": "chat.completion",
@@ -992,8 +1075,8 @@ def chat_completions(req: ChatRequest):
992
  "mode": req.mode,
993
  "choices": [{
994
  "index": 0,
995
- "message": {"role": "assistant", "content": text},
996
- "finish_reason": "stop"
997
  }],
998
  "usage": {
999
  "prompt_tokens": prompt_tokens,
@@ -1212,6 +1295,8 @@ async def chat_completions_async(req: ChatRequest):
1212
  t0 = time.time()
1213
 
1214
  if backend == "transformers":
 
 
1215
  tokenizer = state["tokenizer"]
1216
  model = state["model"]
1217
 
@@ -1237,6 +1322,8 @@ async def chat_completions_async(req: ChatRequest):
1237
  completion_tokens = len(generated)
1238
 
1239
  elif backend == "gguf":
 
 
1240
  llm = state["model"]
1241
  safe_chat = sanitize_chat_for_gguf(chat)
1242
  result = llm.create_chat_completion(
@@ -1250,6 +1337,8 @@ async def chat_completions_async(req: ChatRequest):
1250
  completion_tokens = usage.get("completion_tokens", 0)
1251
 
1252
  elif backend == "kilo":
 
 
1253
  model_id = state["model"]
1254
  kilo_api_key = os.environ.get("KILO_API_KEY")
1255
  if not kilo_api_key:
@@ -1310,6 +1399,9 @@ async def chat_completions_async(req: ChatRequest):
1310
  "max_tokens": max_tokens,
1311
  "temperature": temperature if temperature else 0.0,
1312
  }
 
 
 
1313
 
1314
  async with httpx.AsyncClient() as client:
1315
  resp = await client.post(
@@ -1334,7 +1426,9 @@ async def chat_completions_async(req: ChatRequest):
1334
  if "choices" not in data or not data["choices"]:
1335
  raise APIError(f"Resposta inválida da OpenRouter API: {data}")
1336
 
1337
- text = data["choices"][0]["message"]["content"]
 
 
1338
  usage = data.get("usage", {})
1339
  prompt_tokens = usage.get("prompt_tokens", 0)
1340
  completion_tokens = usage.get("completion_tokens", 0)
@@ -1355,6 +1449,9 @@ async def chat_completions_async(req: ChatRequest):
1355
  "max_tokens": max_tokens,
1356
  "temperature": temperature if temperature else 0.0,
1357
  }
 
 
 
1358
 
1359
  payload_bytes = len(json.dumps(payload))
1360
  logger.info(f"[GROQ DEBUG] Payload size: {payload_bytes} bytes, Messages: {len(chat)}")
@@ -1382,7 +1479,9 @@ async def chat_completions_async(req: ChatRequest):
1382
  if "choices" not in data or not data["choices"]:
1383
  raise APIError(f"Resposta inválida da Groq API: {data}")
1384
 
1385
- text = data["choices"][0]["message"]["content"]
 
 
1386
  usage = data.get("usage", {})
1387
  prompt_tokens = usage.get("prompt_tokens", 0)
1388
  completion_tokens = usage.get("completion_tokens", 0)
@@ -1403,6 +1502,9 @@ async def chat_completions_async(req: ChatRequest):
1403
  "max_tokens": max_tokens,
1404
  "temperature": temperature if temperature else 0.0,
1405
  }
 
 
 
1406
 
1407
  async with httpx.AsyncClient() as client:
1408
  resp = await client.post(
@@ -1427,7 +1529,9 @@ async def chat_completions_async(req: ChatRequest):
1427
  if "choices" not in data or not data["choices"]:
1428
  raise APIError(f"Resposta inválida da Mistral API: {data}")
1429
 
1430
- text = data["choices"][0]["message"]["content"]
 
 
1431
  usage = data.get("usage", {})
1432
  prompt_tokens = usage.get("prompt_tokens", 0)
1433
  completion_tokens = usage.get("completion_tokens", 0)
@@ -1446,6 +1550,9 @@ async def chat_completions_async(req: ChatRequest):
1446
  "temperature": temperature if temperature else 0.0,
1447
  }
1448
 
 
 
 
1449
  if model_id.startswith("gemini"):
1450
  gemini_payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
1451
 
@@ -1476,7 +1583,10 @@ async def chat_completions_async(req: ChatRequest):
1476
 
1477
  logger.info(f"[GEMINI DEBUG] finishReason={data['candidates'][0].get('finishReason')}, content={data['candidates'][0].get('content', {})}")
1478
 
1479
- text = data["candidates"][0]["content"]["parts"][0]["text"]
 
 
 
1480
  usage = data.get("usageMetadata", {})
1481
  prompt_tokens = usage.get("promptTokenCount", 0)
1482
  completion_tokens = usage.get("candidatesTokenCount", 0)
@@ -1491,6 +1601,25 @@ async def chat_completions_async(req: ChatRequest):
1491
  elapsed = time.time() - t0
1492
  logger.info(f"Resposta gerada em {elapsed:.1f}s ({backend}, web={req.web})")
1493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1494
  return {
1495
  "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
1496
  "object": "chat.completion",
@@ -1499,8 +1628,8 @@ async def chat_completions_async(req: ChatRequest):
1499
  "mode": req.mode,
1500
  "choices": [{
1501
  "index": 0,
1502
- "message": {"role": "assistant", "content": text},
1503
- "finish_reason": "stop"
1504
  }],
1505
  "usage": {
1506
  "prompt_tokens": prompt_tokens,
 
411
  reload_indexes()
412
 
413
  # ── Schemas ────────────────────────────────────────────────────────────────────
414
+ class ToolFunctionParameters(BaseModel):
415
+ type: str = "object"
416
+ properties: dict = {}
417
+ required: Optional[List[str]] = None
418
+
419
+
420
+ class ToolFunction(BaseModel):
421
+ name: str
422
+ description: Optional[str] = None
423
+ parameters: Optional[ToolFunctionParameters] = None
424
+
425
+
426
+ class Tool(BaseModel):
427
+ type: Literal["function"] = "function"
428
+ function: ToolFunction
429
+
430
+
431
  class Message(BaseModel):
432
  role: Literal["system", "user", "assistant"]
433
  content: constr(min_length=1, max_length=10000)
 
447
  messages: List[Message] = Field(min_length=1, description="Lista de mensagens")
448
  max_tokens: Optional[int] = Field(default=8192, ge=1, le=1000000, description="Máximo de tokens")
449
  temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0, description="Temperatura de geração")
450
+ tools: Optional[List[Tool]] = Field(default=None, description="Ferramentas disponíveis (formato OpenAI)")
451
+ tool_choice: Optional[str] = Field(default="auto", description="'auto', 'none', ou nome de uma tool")
452
 
453
  @field_validator("model")
454
  @classmethod
 
664
  return payload
665
 
666
 
667
+ def convert_tools_to_gemini_format(tools):
668
+ if not tools:
669
+ return None
670
+ declarations = []
671
+ for t in tools:
672
+ fn = t.function if hasattr(t, "function") else t["function"]
673
+ name = fn.name if hasattr(fn, "name") else fn["name"]
674
+ description = (fn.description if hasattr(fn, "description") else fn.get("description")) or ""
675
+ parameters = (
676
+ fn.parameters.model_dump(exclude_none=True)
677
+ if hasattr(fn, "parameters") and fn.parameters
678
+ else {"type": "object", "properties": {}}
679
+ )
680
+ declarations.append({
681
+ "name": name,
682
+ "description": description,
683
+ "parameters": parameters,
684
+ })
685
+ return [{"functionDeclarations": declarations}]
686
+
687
+
688
  # ── Helper: Sanitiza mensagens para GGUF ──────────────────────────────────────
689
  def sanitize_chat_for_gguf(chat_messages):
690
  messages = [{"role": m["role"], "content": m["content"]} for m in chat_messages]
 
725
  t0 = time.time()
726
 
727
  if backend == "transformers":
728
+ if req.tools:
729
+ logger.warning("Backend 'transformers' não suporta tool calling — tools será ignorado.")
730
  tokenizer = state["tokenizer"]
731
  model = state["model"]
732
 
 
752
  completion_tokens = len(generated)
753
 
754
  elif backend == "gguf":
755
+ if req.tools:
756
+ logger.warning("Backend 'gguf' não suporta tool calling — tools será ignorado.")
757
  llm = state["model"]
758
  safe_chat = sanitize_chat_for_gguf(chat)
759
  result = llm.create_chat_completion(
 
767
  completion_tokens = usage.get("completion_tokens", 0)
768
 
769
  elif backend == "kilo":
770
+ if req.tools:
771
+ logger.warning("Backend 'kilo' não suporta tool calling — tools será ignorado.")
772
  model_id = state["model"]
773
  kilo_api_key = os.environ.get("KILO_API_KEY")
774
  if not kilo_api_key:
 
833
  "max_tokens": max_tokens,
834
  "temperature": temperature if temperature else 0.0,
835
  }
836
+ if req.tools:
837
+ payload["tools"] = [t.model_dump(exclude_none=True) for t in req.tools]
838
+ payload["tool_choice"] = req.tool_choice or "auto"
839
 
840
+ async with httpx.AsyncClient() as client:
 
 
841
  resp = await client.post(
842
  "https://openrouter.ai/api/v1/chat/completions",
843
  json=payload,
 
862
  if "choices" not in data or not data["choices"]:
863
  raise APIError(f"Resposta inválida da OpenRouter API: {data}")
864
 
865
+ message_obj = data["choices"][0]["message"]
866
+ text = message_obj.get("content", "") or ""
867
+ tool_calls = message_obj.get("tool_calls")
868
  usage = data.get("usage", {})
869
  prompt_tokens = usage.get("prompt_tokens", 0)
870
  completion_tokens = usage.get("completion_tokens", 0)
 
885
  "max_tokens": max_tokens,
886
  "temperature": temperature if temperature else 0.0,
887
  }
888
+ if req.tools:
889
+ payload["tools"] = [t.model_dump(exclude_none=True) for t in req.tools]
890
+ payload["tool_choice"] = req.tool_choice or "auto"
891
 
892
  payload_bytes = len(json.dumps(payload))
893
  logger.info(f"[GROQ DEBUG] Payload size: {payload_bytes} bytes, Messages: {len(chat)}")
 
919
  if "choices" not in data or not data["choices"]:
920
  raise APIError(f"Resposta inválida da Groq API: {data}")
921
 
922
+ message_obj = data["choices"][0]["message"]
923
+ text = message_obj.get("content", "") or ""
924
+ tool_calls = message_obj.get("tool_calls")
925
  usage = data.get("usage", {})
926
  prompt_tokens = usage.get("prompt_tokens", 0)
927
  completion_tokens = usage.get("completion_tokens", 0)
 
942
  "max_tokens": max_tokens,
943
  "temperature": temperature if temperature else 0.0,
944
  }
945
+ if req.tools:
946
+ payload["tools"] = [t.model_dump(exclude_none=True) for t in req.tools]
947
+ payload["tool_choice"] = req.tool_choice or "auto"
948
 
949
  @async_retry_with_backoff(max_retries=3, initial_delay=1, backoff_factor=2)
950
  async def call_mistral_api():
 
973
  if "choices" not in data or not data["choices"]:
974
  raise APIError(f"Resposta inválida da Mistral API: {data}")
975
 
976
+ message_obj = data["choices"][0]["message"]
977
+ text = message_obj.get("content", "") or ""
978
+ tool_calls = message_obj.get("tool_calls")
979
  usage = data.get("usage", {})
980
  prompt_tokens = usage.get("prompt_tokens", 0)
981
  completion_tokens = usage.get("completion_tokens", 0)
 
994
  "temperature": temperature if temperature else 0.0,
995
  }
996
 
997
+ if req.tools:
998
+ gemini_payload["tools"] = convert_tools_to_gemini_format(req.tools)
999
+
1000
  if model_id.startswith("gemini"):
1001
  gemini_payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
1002
 
 
1031
 
1032
  logger.info(f"[GEMINI DEBUG] finishReason={data['candidates'][0].get('finishReason')}, content={data['candidates'][0].get('content', {})}")
1033
 
1034
+ parts = data["candidates"][0]["content"]["parts"]
1035
+ text_parts = [p.get("text", "") for p in parts if "text" in p]
1036
+ text = "".join(text_parts)
1037
+ function_calls = [p["functionCall"] for p in parts if "functionCall" in p]
1038
  usage = data.get("usageMetadata", {})
1039
  prompt_tokens = usage.get("promptTokenCount", 0)
1040
  completion_tokens = usage.get("candidatesTokenCount", 0)
 
1042
  finish_reason = "stop"
1043
  if data["candidates"][0].get("finishReason") == "MAX_TOKENS":
1044
  finish_reason = "length"
 
1045
  else:
1046
  raise BackendError("Backend inválido")
1047
 
1048
  elapsed = time.time() - t0
1049
  logger.info(f"Resposta gerada em {elapsed:.1f}s ({backend}, web={req.web})")
1050
 
1051
+ message = {"role": "assistant", "content": text}
1052
+ openai_tool_calls = None
1053
+ if tool_calls:
1054
+ openai_tool_calls = tool_calls
1055
+ elif function_calls:
1056
+ openai_tool_calls = [
1057
+ {
1058
+ "id": f"call_{uuid.uuid4().hex[:8]}",
1059
+ "type": "function",
1060
+ "function": {
1061
+ "name": fc["name"],
1062
+ "arguments": json.dumps(fc.get("args", {})),
1063
+ },
1064
+ }
1065
+ for fc in function_calls
1066
+ ]
1067
+ if openai_tool_calls:
1068
+ message["tool_calls"] = openai_tool_calls
1069
+
1070
  return {
1071
  "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
1072
  "object": "chat.completion",
 
1075
  "mode": req.mode,
1076
  "choices": [{
1077
  "index": 0,
1078
+ "message": message,
1079
+ "finish_reason": "tool_calls" if openai_tool_calls else (finish_reason or "stop"),
1080
  }],
1081
  "usage": {
1082
  "prompt_tokens": prompt_tokens,
 
1295
  t0 = time.time()
1296
 
1297
  if backend == "transformers":
1298
+ if req.tools:
1299
+ logger.warning("Backend 'transformers' não suporta tool calling — tools será ignorado.")
1300
  tokenizer = state["tokenizer"]
1301
  model = state["model"]
1302
 
 
1322
  completion_tokens = len(generated)
1323
 
1324
  elif backend == "gguf":
1325
+ if req.tools:
1326
+ logger.warning("Backend 'gguf' não suporta tool calling — tools será ignorado.")
1327
  llm = state["model"]
1328
  safe_chat = sanitize_chat_for_gguf(chat)
1329
  result = llm.create_chat_completion(
 
1337
  completion_tokens = usage.get("completion_tokens", 0)
1338
 
1339
  elif backend == "kilo":
1340
+ if req.tools:
1341
+ logger.warning("Backend 'kilo' não suporta tool calling — tools será ignorado.")
1342
  model_id = state["model"]
1343
  kilo_api_key = os.environ.get("KILO_API_KEY")
1344
  if not kilo_api_key:
 
1399
  "max_tokens": max_tokens,
1400
  "temperature": temperature if temperature else 0.0,
1401
  }
1402
+ if req.tools:
1403
+ payload["tools"] = [t.model_dump(exclude_none=True) for t in req.tools]
1404
+ payload["tool_choice"] = req.tool_choice or "auto"
1405
 
1406
  async with httpx.AsyncClient() as client:
1407
  resp = await client.post(
 
1426
  if "choices" not in data or not data["choices"]:
1427
  raise APIError(f"Resposta inválida da OpenRouter API: {data}")
1428
 
1429
+ message_obj = data["choices"][0]["message"]
1430
+ text = message_obj.get("content", "") or ""
1431
+ tool_calls = message_obj.get("tool_calls")
1432
  usage = data.get("usage", {})
1433
  prompt_tokens = usage.get("prompt_tokens", 0)
1434
  completion_tokens = usage.get("completion_tokens", 0)
 
1449
  "max_tokens": max_tokens,
1450
  "temperature": temperature if temperature else 0.0,
1451
  }
1452
+ if req.tools:
1453
+ payload["tools"] = [t.model_dump(exclude_none=True) for t in req.tools]
1454
+ payload["tool_choice"] = req.tool_choice or "auto"
1455
 
1456
  payload_bytes = len(json.dumps(payload))
1457
  logger.info(f"[GROQ DEBUG] Payload size: {payload_bytes} bytes, Messages: {len(chat)}")
 
1479
  if "choices" not in data or not data["choices"]:
1480
  raise APIError(f"Resposta inválida da Groq API: {data}")
1481
 
1482
+ message_obj = data["choices"][0]["message"]
1483
+ text = message_obj.get("content", "") or ""
1484
+ tool_calls = message_obj.get("tool_calls")
1485
  usage = data.get("usage", {})
1486
  prompt_tokens = usage.get("prompt_tokens", 0)
1487
  completion_tokens = usage.get("completion_tokens", 0)
 
1502
  "max_tokens": max_tokens,
1503
  "temperature": temperature if temperature else 0.0,
1504
  }
1505
+ if req.tools:
1506
+ payload["tools"] = [t.model_dump(exclude_none=True) for t in req.tools]
1507
+ payload["tool_choice"] = req.tool_choice or "auto"
1508
 
1509
  async with httpx.AsyncClient() as client:
1510
  resp = await client.post(
 
1529
  if "choices" not in data or not data["choices"]:
1530
  raise APIError(f"Resposta inválida da Mistral API: {data}")
1531
 
1532
+ message_obj = data["choices"][0]["message"]
1533
+ text = message_obj.get("content", "") or ""
1534
+ tool_calls = message_obj.get("tool_calls")
1535
  usage = data.get("usage", {})
1536
  prompt_tokens = usage.get("prompt_tokens", 0)
1537
  completion_tokens = usage.get("completion_tokens", 0)
 
1550
  "temperature": temperature if temperature else 0.0,
1551
  }
1552
 
1553
+ if req.tools:
1554
+ gemini_payload["tools"] = convert_tools_to_gemini_format(req.tools)
1555
+
1556
  if model_id.startswith("gemini"):
1557
  gemini_payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
1558
 
 
1583
 
1584
  logger.info(f"[GEMINI DEBUG] finishReason={data['candidates'][0].get('finishReason')}, content={data['candidates'][0].get('content', {})}")
1585
 
1586
+ parts = data["candidates"][0]["content"]["parts"]
1587
+ text_parts = [p.get("text", "") for p in parts if "text" in p]
1588
+ text = "".join(text_parts)
1589
+ function_calls = [p["functionCall"] for p in parts if "functionCall" in p]
1590
  usage = data.get("usageMetadata", {})
1591
  prompt_tokens = usage.get("promptTokenCount", 0)
1592
  completion_tokens = usage.get("candidatesTokenCount", 0)
 
1601
  elapsed = time.time() - t0
1602
  logger.info(f"Resposta gerada em {elapsed:.1f}s ({backend}, web={req.web})")
1603
 
1604
+ message = {"role": "assistant", "content": text}
1605
+ openai_tool_calls = None
1606
+ if tool_calls:
1607
+ openai_tool_calls = tool_calls
1608
+ elif function_calls:
1609
+ openai_tool_calls = [
1610
+ {
1611
+ "id": f"call_{uuid.uuid4().hex[:8]}",
1612
+ "type": "function",
1613
+ "function": {
1614
+ "name": fc["name"],
1615
+ "arguments": json.dumps(fc.get("args", {})),
1616
+ },
1617
+ }
1618
+ for fc in function_calls
1619
+ ]
1620
+ if openai_tool_calls:
1621
+ message["tool_calls"] = openai_tool_calls
1622
+
1623
  return {
1624
  "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
1625
  "object": "chat.completion",
 
1628
  "mode": req.mode,
1629
  "choices": [{
1630
  "index": 0,
1631
+ "message": message,
1632
+ "finish_reason": "tool_calls" if openai_tool_calls else (finish_reason or "stop"),
1633
  }],
1634
  "usage": {
1635
  "prompt_tokens": prompt_tokens,