From 10383916a6b2ef4d0cc964d7473a2ec6ffa70142 Mon Sep 17 00:00:00 2001 From: Marcos Lohmann Date: Fri, 12 Sep 2025 11:31:24 -0300 Subject: [PATCH] Update app.py --- GenAI/proxy/app.py | 407 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 338 insertions(+), 69 deletions(-) diff --git a/GenAI/proxy/app.py b/GenAI/proxy/app.py index 7518931..df94649 100644 --- a/GenAI/proxy/app.py +++ b/GenAI/proxy/app.py @@ -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///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////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////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////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///) +# ========================== + +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////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////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//content", methods=["GET"]) +#@app.route("/genai/v1/files//content", methods=["GET"]) +@app.route("/genai////v1/files//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////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////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////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)