Update app.py

This commit is contained in:
Marcos Lohmann
2025-09-12 11:31:24 -03:00
committed by GitHub
parent 2f76ab337d
commit 10383916a6

View File

@@ -1,26 +1,31 @@
# api.py — OCI GenAI + OpenAI v1 Compatibility
# api.py — OCI GenAI + OpenAI v1 Compatibility (files + images + multimodal)
# -----------------------------------------------------------------------------
# Requisitos:
# pip install flask oci requests
# pip install flask oci requests pillow
# Execução:
# export API_KEY="minha-chave"
# export GENAI_BUCKET="lohmann-ai-br"
# export GENAI_UPLOAD_PREFIX="genai-uploads/"
# python api.py # porta 8000
# -----------------------------------------------------------------------------
from flask import Flask, request, jsonify, abort, Response, stream_with_context
from flask import Flask, request, jsonify, abort, Response, stream_with_context, send_file
import oci
import requests
import os
import io
import json
import uuid
import base64
import time
import mimetypes
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Generator
app = Flask(__name__)
# ==========================
# Configuração
# Configuração e Autenticação OCI
# ==========================
def load_config(config_file="/home/app/credentials.conf"):
@@ -57,6 +62,118 @@ if not TEST_MODE:
print("Executando em modo de teste...")
TEST_MODE = True
# ==========================
# Variáveis de Bucket / Uploads
# ==========================
BUCKET_NAME = os.environ.get("GENAI_BUCKET", "lohmann-ai-br")
UPLOAD_PREFIX = os.environ.get("GENAI_UPLOAD_PREFIX", "genai-uploads/")
if UPLOAD_PREFIX and not UPLOAD_PREFIX.endswith("/"):
UPLOAD_PREFIX += "/"
object_client = None
namespace = None
region = config.get("region") or os.environ.get("OCI_REGION", "us-chicago-1")
if not TEST_MODE:
try:
object_client = oci.object_storage.ObjectStorageClient(config)
namespace = object_client.get_namespace().data
except Exception as e:
print(f"Erro ao inicializar ObjectStorageClient: {e}")
TEST_MODE = True
# Session store para mapear file_id -> object_name (para fallback de download)
FILE_INDEX: Dict[str, str] = {}
# ==========================
# Segurança API
# ==========================
def check_api_key():
expected_key = os.environ.get("API_KEY")
if not expected_key:
print("AVISO: API_KEY não configurada nas variáveis de ambiente.")
return
provided_key = request.headers.get("X-API-Key")
if provided_key != expected_key:
abort(401, description="Chave de API inválida ou ausente.")
@app.before_request
def before_all_requests():
check_api_key()
# ==========================
# Helpers: Signed URL (PAR) + Upload
# ==========================
def guess_mime(filename: str, default: str = "application/octet-stream") -> str:
mt, _ = mimetypes.guess_type(filename)
return mt or default
def create_par_for_object(object_name: str, hours_valid: int = 1) -> str:
"""
Cria um Pre-Authenticated Request (PAR) para leitura do objeto.
Retorna a URL completa (https://objectstorage.region.oraclecloud.com{accessUri})
"""
if TEST_MODE:
return f"https://objectstorage.{region}.oraclecloud.com/test/{object_name}"
expires = datetime.utcnow() + timedelta(hours=hours_valid)
details = oci.object_storage.models.CreatePreauthenticatedRequestDetails(
name=f"par-{uuid.uuid4().hex[:8]}",
access_type="ObjectRead",
time_expires=expires,
bucket_listing_action=None,
object_name=object_name
)
par = object_client.create_preauthenticated_request(
namespace_name=namespace,
bucket_name=BUCKET_NAME,
create_preauthenticated_request_details=details
).data
# access_uri começa com /p/...
base = f"https://objectstorage.{region}.oraclecloud.com"
return base + par.access_uri
def upload_file_to_bucket(file_storage, filename: str) -> Dict[str, Any]:
"""
Faz upload do arquivo para o bucket e retorna metadata + signed URL (PAR).
"""
file_storage.stream.seek(0)
content = file_storage.read()
size = len(content)
if TEST_MODE:
file_id = f"file-{uuid.uuid4().hex[:12]}"
url = f"https://objectstorage.{region}.oraclecloud.com/test/{UPLOAD_PREFIX}{file_id}_{filename}"
FILE_INDEX[file_id] = f"{UPLOAD_PREFIX}{file_id}_{filename}"
return {"id": file_id, "object": "file", "filename": filename, "bytes": size, "url": url}
object_name = f"{UPLOAD_PREFIX}{uuid.uuid4().hex}_{filename}"
object_client.put_object(
namespace,
BUCKET_NAME,
object_name,
content,
content_type=guess_mime(filename, "application/octet-stream")
)
url = create_par_for_object(object_name, hours_valid=24)
file_id = f"file-{uuid.uuid4().hex[:12]}"
FILE_INDEX[file_id] = object_name
return {
"id": file_id,
"object": "file",
"filename": filename,
"bytes": size,
"url": url
}
def get_signed_url_from_file_id(file_id: str, hours_valid: int = 24) -> Optional[str]:
if file_id in FILE_INDEX:
obj = FILE_INDEX[file_id]
return create_par_for_object(obj, hours_valid=hours_valid) if not TEST_MODE else f"https://test/{obj}"
return None
# ==========================
# Modelos suportados (defaults)
# ==========================
@@ -95,7 +212,7 @@ SUPPORTED_MODELS: Dict[str, Dict[str, Any]] = {
# ==========================
SESSION_STORE = {}
SESSION_TTL = timedelta(hours=1)
SESSION_TTL = timedelta(hours=2)
def session_controller(region, agent_endpoint_id, channel, cuid):
"""
@@ -234,7 +351,7 @@ def call_inference_model(region, compartment_id, model_id, prompt):
return {"error": str(e)}
# ==========================
# Utilitários (OpenAI v1 compat)
# Utilitários (OpenAI v1)
# ==========================
ROLE_MAP = {
@@ -243,6 +360,29 @@ ROLE_MAP = {
"assistant": "ASSISTANT",
}
def ensure_data_url(image_url: str) -> str:
"""
Garante que a imagem seja uma data URL (base64).
Se já for data: retorna como está.
Se for http(s): baixa, infere MIME e converte para data URL.
"""
if not image_url:
return image_url
if image_url.startswith("data:"):
return image_url
try:
resp = requests.get(image_url, timeout=30)
resp.raise_for_status()
content = resp.content
# tenta inferir mime pelo header; fallback pela extensão
mime = resp.headers.get("Content-Type") or guess_mime(image_url, "image/jpeg")
b64 = base64.b64encode(content).decode("utf-8")
return f"data:{mime};base64,{b64}"
except Exception as e:
print(f"[warn] Falha ao baixar imagem '{image_url}': {e}")
# retorna URL original (alguns modelos podem aceitar URL remota)
return image_url
def resolve_model_and_params(body: Dict[str, Any], path_model_id: str) -> Dict[str, Any]:
"""
Resolve o OCID do modelo a partir de:
@@ -287,36 +427,49 @@ def resolve_model_and_params(body: Dict[str, Any], path_model_id: str) -> Dict[s
def to_oci_messages(openai_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Converte mensagens no formato OpenAI para o formato da OCI.
Converte mensagens no formato OpenAI para um payload genérico (que depois vira objetos do SDK).
Suporta:
- content string
- content list com {type:"text", text:"..."} e {type:"image_url", image_url:{url:"..."}}
"""
oci_msgs: List[Dict[str, Any]] = []
for m in openai_messages:
role = ROLE_MAP.get(str(m.get("role", "")).lower(), "USER")
content = m.get("content", "")
if isinstance(content, list):
text_parts = []
for p in content:
if isinstance(p, dict) and p.get("type") == "text":
text_parts.append(p.get("text", ""))
elif isinstance(p, str):
text_parts.append(p)
content_str = "\n".join([t for t in text_parts if t])
elif isinstance(content, str):
content_str = content
else:
content_str = str(content)
oci_msgs.append({
"role": role,
"content": [
{"type": "TEXT", "text": content_str}
]
})
parts: List[Dict[str, Any]] = []
if isinstance(content, list):
for p in content:
# TEXT
if isinstance(p, dict) and p.get("type") == "text":
txt = p.get("text", "")
if txt:
parts.append({"type": "TEXT", "text": txt})
# IMAGE
elif isinstance(p, dict) and p.get("type") == "image_url":
url = ""
if isinstance(p.get("image_url"), dict):
url = p.get("image_url", {}).get("url", "")
elif isinstance(p.get("image_url"), str):
url = p.get("image_url")
if url:
data_url = ensure_data_url(url)
parts.append({"type": "IMAGE_URL", "url": data_url})
# strings soltas tratadas como texto
elif isinstance(p, str):
parts.append({"type": "TEXT", "text": p})
elif isinstance(content, str):
parts.append({"type": "TEXT", "text": content})
else:
# fallback serialization
parts.append({"type": "TEXT", "text": json.dumps(content, ensure_ascii=False)})
oci_msgs.append({"role": role, "content": parts})
return oci_msgs
def build_oci_chat_payload(messages: List[Dict[str, Any]], params: Dict[str, Any]) -> Dict[str, Any]:
"""
Monta o payload para /actions/chat da OCI.
Monta o payload para /actions/chat da OCI (genérico).
"""
payload = {"messages": messages}
@@ -344,12 +497,13 @@ def build_oci_chat_payload(messages: List[Dict[str, Any]], params: Dict[str, Any
def oci_chat_invoke(region: str, compartment_id: str, model_ocid: str, oci_payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Invoca o /actions/chat da OCI. Em TEST_MODE retorna dry-run.
Converte conteúdo TEXT/IMAGE_URL em TextContent/ImageContent do SDK.
"""
# DEBUG: imprimir o payload que vai para a OCI (útil para validar 'role')
print(">>> OCI CHAT REQUEST (payload que será enviado):")
print(json.dumps(oci_payload, ensure_ascii=False, indent=2))
if TEST_MODE:
# Retorno simulado
return {
"dry_run": True,
"note": "TEST_MODE=True — retorno simulado.",
@@ -366,7 +520,6 @@ def oci_chat_invoke(region: str, compartment_id: str, model_ocid: str, oci_paylo
timeout=(10, 240)
)
# Monta ChatDetails + GenericChatRequest com api_format=GENERIC
chat_detail = oci.generative_ai_inference.models.ChatDetails()
generic = oci.generative_ai_inference.models.GenericChatRequest()
generic.api_format = oci.generative_ai_inference.models.BaseChatRequest.API_FORMAT_GENERIC
@@ -378,9 +531,17 @@ def oci_chat_invoke(region: str, compartment_id: str, model_ocid: str, oci_paylo
sdk_msg.role = m["role"]
parts = []
for c in m["content"]:
tc = oci.generative_ai_inference.models.TextContent()
tc.text = c.get("text", "")
parts.append(tc)
ctype = c.get("type")
if ctype == "TEXT":
tc = oci.generative_ai_inference.models.TextContent()
tc.text = c.get("text", "")
parts.append(tc)
elif ctype == "IMAGE_URL":
ic = oci.generative_ai_inference.models.ImageContent()
iu = oci.generative_ai_inference.models.ImageUrl()
iu.url = c.get("url", "")
ic.image_url = iu
parts.append(ic)
sdk_msg.content = parts
sdk_messages.append(sdk_msg)
@@ -396,13 +557,8 @@ def oci_chat_invoke(region: str, compartment_id: str, model_ocid: str, oci_paylo
if "presence_penalty" in oci_payload:
generic.presence_penalty = oci_payload["presence_penalty"]
if "max_completion_tokens" in oci_payload:
# Algumas versões do SDK usam 'max_tokens'; mantemos ambos por segurança
generic.max_tokens = oci_payload["max_completion_tokens"]
# Extras (se suportados pelo modelo)
# OBS: reasoning_effort/verbosity são específicos e podem não ter
# mapeamento direto no SDK — ficam omitidos se não houver suporte.
chat_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(model_id=model_ocid)
chat_detail.chat_request = generic
chat_detail.compartment_id = compartment_id
@@ -410,16 +566,16 @@ def oci_chat_invoke(region: str, compartment_id: str, model_ocid: str, oci_paylo
chat_response = client.chat(chat_detail)
data = chat_response.data
# Normalize saída
if hasattr(data, "chat_response") and data.chat_response and data.chat_response.choices:
choice = data.chat_response.choices[0]
# Tenta pegar o texto do primeiro bloco
text = None
if choice.message and choice.message.content:
if hasattr(choice.message.content[0], "text"):
text = choice.message.content[0].text
# captura primeiro bloco de texto
for block in choice.message.content:
if hasattr(block, "text") and block.text:
text = block.text
break
return {"output_text": text, "raw": "sdk"}
# fallback
return {"output_text": None, "raw": "unknown"}
except Exception as e:
return {"error": f"Falha ao chamar OCI: {e}"}
@@ -484,24 +640,7 @@ def sse_chat_stream(model_label: str, full_text: str) -> Generator[str, None, No
yield "data: [DONE]\n\n"
# ==========================
# Segurança
# ==========================
def check_api_key():
expected_key = os.environ.get("API_KEY")
if not expected_key:
print("AVISO: API_KEY não configurada nas variáveis de ambiente.")
return
provided_key = request.headers.get("X-API-Key")
if provided_key != expected_key:
abort(401, description="Chave de API inválida ou ausente.")
@app.before_request
def before_all_requests():
check_api_key()
# ==========================
# Endpoints existentes (mantidos)
# Endpoints nativos
# ==========================
@app.route("/", methods=["GET"])
@@ -514,11 +653,7 @@ def var_copy(myvar):
@app.route("/genai-agent/<region>/<agent_endpoint_id>/session", methods=["POST"])
def manage_session(region, agent_endpoint_id):
"""
Reaproveita ou cria uma sessão nova com base em channel + cuid.
"""
data = request.get_json() or {}
# DEBUG:
print(">>> /genai-agent/.../session payload recebido:")
#print(data)
channel = data.get("channel")
@@ -531,7 +666,6 @@ def manage_session(region, agent_endpoint_id):
@app.route("/genai-agent/<region>/<agent_endpoint_id>/<session_id>/chat", methods=["POST"])
def agent_chat(region, agent_endpoint_id, session_id):
data = request.get_json() or {}
# DEBUG:
print(">>> /genai-agent/.../chat payload recebido:")
#print(data)
user_message = data.get("userMessage")
@@ -543,7 +677,6 @@ def agent_chat(region, agent_endpoint_id, session_id):
@app.route("/genai/<region>/<compartment_id>/<model_id>/inference", methods=["POST"])
def inference(region, compartment_id, model_id):
data = request.get_json() or {}
# DEBUG:
print(">>> /inference request body:")
#print(data)
prompt = data.get("prompt")
@@ -553,7 +686,7 @@ def inference(region, compartment_id, model_id):
return jsonify(response_data)
# ==========================
# Novos endpoints OpenAI v1 compat
# Endpoints OpenAI v1 compat — CHAT
# ==========================
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/chat/completions", methods=["POST"])
@@ -563,7 +696,6 @@ def v1_chat_completions(region, compartment_id, path_model_id):
except Exception as e:
return jsonify({"error": f"JSON inválido: {e}"}), 400
# DEBUG: imprimir o que chegou
print(">>> /v1/chat/completions body recebido:")
print(body)
@@ -577,6 +709,7 @@ def v1_chat_completions(region, compartment_id, path_model_id):
if not isinstance(msgs, list) or not msgs:
return jsonify({"error": "Campo 'messages' é obrigatório e deve ser uma lista."}), 400
# Suporte multimodal (text + image_url)
oci_msgs = to_oci_messages(msgs)
oci_payload = build_oci_chat_payload(oci_msgs, resolved["params"])
@@ -607,7 +740,6 @@ def v1_text_completions(region, compartment_id, path_model_id):
except Exception as e:
return jsonify({"error": f"JSON inválido: {e}"}), 400
# DEBUG:
print(">>> /v1/completions body recebido:")
print(body)
@@ -621,7 +753,7 @@ def v1_text_completions(region, compartment_id, path_model_id):
if prompt is None:
return jsonify({"error": "Campo 'prompt' é obrigatório."}), 400
# Compat: empacotar como chat com 1 mensagem user
# Compat: empacotar como chat com 1 mensagem user (texto)
if isinstance(prompt, list):
prompt_text = "\n".join([str(p) for p in prompt])
else:
@@ -651,9 +783,146 @@ def v1_text_completions(region, compartment_id, path_model_id):
return jsonify(to_openai_text_response(model_label, output_text))
# ==========================
# Endpoints OpenAI v1 — FILES
# (com e sem prefixo /genai/<region>/<compartment_id>/<model>)
# ==========================
def _files_upload_handler():
# Compatível com multipart/form-data (OpenAI clients)
if "file" not in request.files:
return jsonify({"error": "Campo 'file' é obrigatório"}), 400
f = request.files["file"]
result = upload_file_to_bucket(f, f.filename)
return jsonify(result)
def _files_list_handler():
if TEST_MODE:
# Lista fictícia em modo teste
return jsonify({"data": [
{"id": fid, "object": "file", "filename": os.path.basename(obj), "bytes": 0}
for fid, obj in FILE_INDEX.items()
]})
resp = object_client.list_objects(namespace, BUCKET_NAME, prefix=UPLOAD_PREFIX)
files = []
for obj in resp.data.objects:
files.append({
"id": f"file-{uuid.uuid4().hex[:12]}",
"object": "file",
"filename": obj.name.replace(UPLOAD_PREFIX, ""),
"bytes": obj.size
})
return jsonify({"data": files})
# Sem prefixo
#@app.route("/v1/files", methods=["POST"])
#@app.route("/genai/v1/files", methods=["POST"])
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/files", methods=["POST"])
def v1_files_upload(region=None, compartment_id=None, path_model_id=None):
return _files_upload_handler()
#@app.route("/v1/files", methods=["GET"])
#@app.route("/genai/v1/files", methods=["GET"])
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/files", methods=["GET"])
def v1_files_list(region=None, compartment_id=None, path_model_id=None):
return _files_list_handler()
#@app.route("/v1/files/<file_id>/content", methods=["GET"])
#@app.route("/genai/v1/files/<file_id>/content", methods=["GET"])
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/files/<file_id>/content", methods=["GET"])
def v1_files_content(file_id, region=None, compartment_id=None, path_model_id=None):
"""
Fallback para servir o conteúdo via Flask (caso cliente não use a signed URL).
"""
if TEST_MODE:
return jsonify({"note": "TEST_MODE — conteúdo não disponível"}), 200
obj = FILE_INDEX.get(file_id)
if not obj:
return jsonify({"error": "file_id não encontrado neste servidor"}), 404
# stream direto do Object Storage
obj_resp = object_client.get_object(namespace, BUCKET_NAME, obj)
data = obj_resp.data.content
# tentativa de inferir mime pelo nome armazenado
filename = os.path.basename(obj)
return send_file(
io.BytesIO(data.read()),
mimetype=guess_mime(filename, "application/octet-stream"),
as_attachment=False,
download_name=filename
)
# ==========================
# Endpoints OpenAI v1 — IMAGES (gera/edita/varia) com retorno via PAR
# ==========================
def _store_image_bytes_and_return_url(image_bytes: bytes, filename: str) -> str:
"""
Armazena bytes no bucket e retorna signed URL.
"""
if TEST_MODE:
return f"https://objectstorage.{region}.oraclecloud.com/test/{UPLOAD_PREFIX}{uuid.uuid4().hex}_{filename}"
object_name = f"{UPLOAD_PREFIX}{uuid.uuid4().hex}_{filename}"
object_client.put_object(
namespace, BUCKET_NAME, object_name, image_bytes, content_type=guess_mime(filename, "image/png")
)
return create_par_for_object(object_name, hours_valid=24)
#@app.route("/v1/images/generations", methods=["POST"])
#@app.route("/genai/v1/images/generations", methods=["POST"])
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/images/generations", methods=["POST"])
def v1_images_generations(region=None, compartment_id=None, path_model_id=None):
"""
Geração de imagens a partir de prompt — placeholder.
Integração com serviço de geração de imagens pode ser plugada aqui.
Por ora, apenas armazena um 'mock' (PNG vazio) e retorna URL.
"""
body = request.form or request.get_json(force=True, silent=True) or {}
prompt = body.get("prompt")
if not prompt:
return jsonify({"error": "Campo 'prompt' é obrigatório"}), 400
# MOCK: cria PNG vazio (1x1) — substitua por integração real de geração.
png_bytes = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHuwKp9w8H2AAAAABJRU5ErkJggg=="
)
url = _store_image_bytes_and_return_url(png_bytes, "generation.png")
return jsonify({"created": int(time.time()), "data": [{"url": url}]})
#@app.route("/v1/images/edits", methods=["POST"])
#@app.route("/genai/v1/images/edits", methods=["POST"])
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/images/edits", methods=["POST"])
def v1_images_edits(region=None, compartment_id=None, path_model_id=None):
"""
Edição de imagem — placeholder.
Espera multipart com 'image' (arquivo base) e 'prompt'.
"""
if "image" not in request.files:
return jsonify({"error": "Campo 'image' (multipart) é obrigatório"}), 400
prompt = request.form.get("prompt", "")
base_img = request.files["image"].read()
# MOCK: retorna a própria imagem (sem edição)
url = _store_image_bytes_and_return_url(base_img, "edit.png")
return jsonify({"created": int(time.time()), "data": [{"url": url, "note": "mock edit"}]})
#@app.route("/v1/images/variations", methods=["POST"])
#@app.route("/genai/v1/images/variations", methods=["POST"])
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/images/variations", methods=["POST"])
def v1_images_variations(region=None, compartment_id=None, path_model_id=None):
"""
Variações de imagem — placeholder.
Espera multipart com 'image' (arquivo base).
"""
if "image" not in request.files:
return jsonify({"error": "Campo 'image' (multipart) é obrigatório"}), 400
base_img = request.files["image"].read()
# MOCK: retorna a própria imagem (sem variação)
url = _store_image_bytes_and_return_url(base_img, "variation.png")
return jsonify({"created": int(time.time()), "data": [{"url": url, "note": "mock variation"}]})
# ==========================
# Main
# ==========================
if __name__ == '__main__':
# Observação: para produção, use um servidor WSGI (gunicorn/uwsgi) atrás de um proxy.
app.run(host='0.0.0.0', port=8000)