Files
oci_tips/GenAI/proxy/app.py
Marcos Lohmann e0aee4b2a7 Update app.py
2025-10-12 20:46:01 -03:00

1118 lines
45 KiB
Python

# api.py — OCI GenAI + OpenAI v1 Compatibility (files + images + multimodal)
# -----------------------------------------------------------------------------
# Requisitos:
# pip install flask oci requests pillow
# # (recomendado p/ CORS em produção)
# pip install flask-cors
# Execução:
# export API_KEY="minha-chave"
# export GENAI_BUCKET="lohmann-ai-br"
# export GENAI_UPLOAD_PREFIX="genai-uploads/"
# # opcional: onde está o JSON dos modelos
# export LLM_CONFIG_PATH="/home/app/llm_models.json"
# # opcional: debug da autenticação
# export DEBUG_AUTH=true
# python api.py # porta 8000
# -----------------------------------------------------------------------------
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
import hmac
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Generator
app = Flask(__name__)
# ==========================
# CORS (habilita para OpenWebUI e browsers)
# ==========================
try:
from flask_cors import CORS
CORS(
app,
resources={r"/*": {"origins": "*"}},
supports_credentials=False,
allow_headers=["Content-Type", "Authorization", "X-API-Key", "X-Channel", "X-Cuid"],
expose_headers=["Content-Type", "Authorization", "X-API-Key"],
methods=["GET", "POST", "OPTIONS"]
)
except Exception as _e:
print("AVISO: flask-cors não instalado; CORS mínimo será aplicado via after_request.")
@app.after_request
def add_cors_headers(resp):
resp.headers.setdefault("Access-Control-Allow-Origin", "*")
resp.headers.setdefault("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
resp.headers.setdefault("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Channel, X-Cuid")
return resp
# ==========================
# Configuração e Autenticação OCI
# ==========================
def load_config(config_file="/home/app/credentials.conf"):
config = {}
try:
with open(config_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
key, value = line.split('=', 1)
config[key.strip()] = value.strip()
return config
except FileNotFoundError:
raise FileNotFoundError(f"Arquivo de configuração '{config_file}' não encontrado")
except Exception as e:
raise Exception(f"Erro ao carregar configuração: {str(e)}")
config = load_config()
TEST_MODE = config.get("test_mode", "false").lower() == "true"
signer = None
if not TEST_MODE:
try:
signer = oci.signer.Signer(
tenancy=config.get("tenancy"),
user=config.get("user"),
fingerprint=config.get("fingerprint"),
private_key_file_location=config.get("key_file"),
pass_phrase=config.get("pass_phrase"),
private_key_content=config.get("key_content"),
)
except Exception as e:
print(f"Erro ao inicializar signer OCI: {e}")
print("Executando em modo de teste...")
TEST_MODE = True
# ==========================
# Segurança API
# ==========================
DEBUG_AUTH = os.environ.get("DEBUG_AUTH", "false").lower() == "true"
def _safe_equals(a: str, b: str) -> bool:
if a is None or b is None:
return False
return hmac.compare_digest(a, b)
def _parse_bearer_token(auth_header: str) -> str:
# Suporta "Bearer <token>" (case-insensitive no prefixo). Opcionalmente aceita "Token <token>".
if not auth_header:
return ""
parts = auth_header.strip().split()
if len(parts) == 2 and parts[0].lower() in ("bearer", "token"):
return parts[1]
return ""
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")
auth_header = request.headers.get("Authorization")
bearer_token = _parse_bearer_token(auth_header)
if DEBUG_AUTH:
print(f"[auth] method={request.method} path={request.path} "
f"X-API-Key={'<set>' if provided_key else '<none>'} "
f"Authorization={'<set>' if auth_header else '<none>'}")
if _safe_equals(provided_key, expected_key) or _safe_equals(bearer_token, expected_key):
return
abort(401, description="Credenciais inválidas ou ausentes. Use X-API-Key ou Authorization: Bearer.")
@app.before_request
def before_all_requests():
# Permitir preflight CORS (OPTIONS) sem autenticação
if request.method == "OPTIONS":
return "", 204
check_api_key()
# ==========================
# 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] = {}
# ==========================
# 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
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 — defaults e JSON externo (hot-reload)
# ==========================
SUPPORTED_MODELS_DEFAULTS: Dict[str, Dict[str, Any]] = {
"gpt5": { # OpenAI GPT-5
"id": "ocid1.generativeaimodel.oc1.us-chicago-1.amaaaaaask7dceyasebknceb4ekbiaiisjtu3fj5i7s4io3ignvg4ip2uyma",
"params": {"max_completion_tokens": 2048, "reasoning_effort": "MEDIUM", "verbosity": "MEDIUM"}
},
"grok3mini": { # xAI Grok-3 Mini
"id": "ocid1.generativeaimodel.oc1.us-chicago-1.amaaaaaask7dceyavwbgai5nlntsd5hngaileroifuoec5qxttmydhq7mykq",
"params": {"temperature": 1, "top_p": 1, "max_tokens": 600}
},
"llama4maverick": { # Meta Llama-4 Maverick
"id": "ocid1.generativeaimodel.oc1.us-chicago-1.amaaaaaask7dceyayjawvuonfkw2ua4bob4rlnnlhs522pafbglivtwlfzta",
"params": {"temperature": 1, "top_p": 0.75, "max_tokens": 600, "frequency_penalty": 0, "presence_penalty": 0}
},
"grokcode": { # xAI Grok-Code-Fast 1
"id": "ocid1.generativeaimodel.oc1.us-chicago-1.amaaaaaask7dceyasw26b5macw3kkrm5czk7ziblk5m7axkgnzrtrtp7ytqa",
"params": {"temperature": 1, "top_p": 1, "top_k": 0, "max_tokens": 600}
},
"commandrplus": { # Cohere Command-R-Plus
"id": "ocid1.generativeaimodel.oc1.us-chicago-1.amaaaaaask7dceyaodm6rdyxmdzlddweh4amobzoo4fatlao2pwnekexmosq",
"params": {"temperature": 1, "top_p": 0.75, "top_k": 0, "max_tokens": 600, "frequency_penalty": 0}
},
"gptoss120": { # OpenAI GPT-OSS 120B
"id": "ocid1.generativeaimodel.oc1.us-chicago-1.amaaaaaask7dceya3eub3uksacl5q35mrigancv6rbppihlg7ihhjofyc22q",
"params": {"temperature": 1, "top_p": 1, "top_k": 0, "max_tokens": 2048, "frequency_penalty": 0, "presence_penalty": 0}
},
"grok4": { # xAI Grok-4
"id": "ocid1.generativeaimodel.oc1.us-chicago-1.amaaaaaask7dceya3bsfz4ogiuv3yc7gcnlry7gi3zzx6tnikg6jltqszm2q",
"params": {"temperature": 1, "top_p": 1, "top_k": 0, "max_tokens": 20000}
}
}
LLM_CONFIG_PATH = os.environ.get("LLM_CONFIG_PATH", "/home/app/llm_models.json")
def get_supported_models() -> Dict[str, Dict[str, Any]]:
"""
Lê SEMPRE o JSON de modelos (hot-reload). Se ausente/ inválido, usa defaults embutidos.
Estrutura esperada:
{
"models": {
"apelido": { "id": "ocid1....", "params": {...} },
...
}
}
"""
try:
with open(LLM_CONFIG_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
models = data.get("models", {})
valid = {k: v for k, v in models.items() if isinstance(v, dict) and v.get("id")}
if not valid:
raise ValueError("Arquivo de modelos não contém 'models' válidos.")
return valid
except Exception as e:
print(f"[warn] Usando SUPPORTED_MODELS_DEFAULTS (motivo: {e})")
return SUPPORTED_MODELS_DEFAULTS
# ==========================
# Session Store (Agente)
# ==========================
SESSION_STORE = {}
SESSION_TTL = timedelta(hours=2)
def session_controller(region, agent_endpoint_id, channel, cuid):
"""
Controla sessões com o agente, reaproveitando se estiver dentro do TTL (2h).
A cada interação, a sessão é renovada (sliding TTL).
"""
session_key = f"{channel}:{cuid}"
now = datetime.utcnow()
existing = SESSION_STORE.get(session_key)
if existing:
last_used = existing["lastUsedAt"]
if now - last_used < SESSION_TTL:
existing["lastUsedAt"] = now
return {"id": existing["sessionId"], "sessionKey": session_key, "reused": True}
if TEST_MODE:
new_session_id = f"test_session_{agent_endpoint_id[:8]}_{int(now.timestamp())}"
SESSION_STORE[session_key] = {
"sessionId": new_session_id, "createdAt": now, "lastUsedAt": now, "sessionKey": session_key
}
print(f"[agent] nova sessão criada (TEST): key={session_key} id={new_session_id}")
return {"id": new_session_id, "sessionKey": session_key, "reused": False}
try:
session = requests.Session()
session.auth = signer
url = (
f"https://agent-runtime.generativeai.{region}.oci.oraclecloud.com/20240531/"
f"agentEndpoints/{agent_endpoint_id}/sessions"
)
payload = {
"description": f"Session for {session_key}",
"displayName": session_key,
"idleTimeoutInSeconds": str(int(SESSION_TTL.total_seconds()))
}
resp = session.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
SESSION_STORE[session_key] = {
"sessionId": data.get("id"), "createdAt": now, "lastUsedAt": now, "sessionKey": session_key
}
print(f"[agent] nova sessão criada: key={session_key} id={data.get('id')}")
data["sessionKey"] = session_key
data["reused"] = False
return data
except Exception as e:
return {"error": str(e), "sessionKey": session_key}
def _invalidate_session(session_key: str):
try:
if session_key in SESSION_STORE:
del SESSION_STORE[session_key]
print(f"[agent] sessão invalidada: key={session_key}")
except Exception:
pass
# ==========================
# Funções de interação (Agente + Inference)
# ==========================
def ask_agent(region, agent_endpoint_id, session_id, user_message):
if TEST_MODE:
return {
"message": f"Resposta simulada para: {user_message}",
"sessionId": session_id,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
session = requests.Session()
session.auth = signer
base_url = f"https://agent-runtime.generativeai.{region}.oci.oraclecloud.com/20240531"
chat_url = f"{base_url}/agentEndpoints/{agent_endpoint_id}/actions/chat"
payload = {"userMessage": user_message, "shouldStream": False, "sessionId": session_id}
try:
response = session.post(chat_url, json=payload)
status = response.status_code
text_body = None
try:
json_body = response.json()
except Exception:
json_body = None
text_body = response.text
if 200 <= status < 300:
return json_body if json_body is not None else {"message": text_body or ""}
else:
return {
"_http_status": status,
"_raw_text": text_body,
"_raw_json": json_body
}
except Exception as e:
return {
"_http_status": 0,
"error": f"Falha de rede ao chamar Agent: {e}"
}
def call_inference_model(region, compartment_id, model_id, prompt):
print(">>> /inference payload recebido:")
if TEST_MODE:
return {"response": f"Resposta simulada para o prompt: {prompt}"}
try:
endpoint = f"https://inference.generativeai.{region}.oci.oraclecloud.com"
generative_ai_inference_client = oci.generative_ai_inference.GenerativeAiInferenceClient(
config=config, service_endpoint=endpoint, retry_strategy=oci.retry.NoneRetryStrategy(), timeout=(10, 240)
)
chat_detail = oci.generative_ai_inference.models.ChatDetails()
content = oci.generative_ai_inference.models.TextContent(); content.text = f"{prompt}"
message = oci.generative_ai_inference.models.Message(); message.role = "USER"; message.content = [content]
chat_request = oci.generative_ai_inference.models.GenericChatRequest()
chat_request.api_format = oci.generative_ai_inference.models.BaseChatRequest.API_FORMAT_GENERIC
chat_request.messages = [message]
chat_request.max_tokens = 50000
chat_request.temperature = 1
chat_request.top_p = 1
chat_request.top_k = 0
chat_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(model_id=model_id)
chat_detail.chat_request = chat_request
chat_detail.compartment_id = compartment_id
chat_response = generative_ai_inference_client.chat(chat_detail)
chat_choices = chat_response.data.chat_response.choices
chat_data = {
"text": chat_choices[0].message.content[0].text,
"finish_reason": chat_choices[0].finish_reason
}
return {"response": chat_data}
except Exception as e:
return {"error": str(e)}
# ==========================
# Utilitários (OpenAI v1)
# ==========================
ROLE_MAP = {"system": "SYSTEM", "user": "USER", "assistant": "ASSISTANT"}
def ensure_data_url(image_url: str) -> str:
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
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}")
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:
1) body['model'] se for chave suportada;
2) body['model'] se for OCID;
3) path_model_id se for chave suportada ou OCID.
Mescla defaults + overrides do corpo (OpenAI-like).
"""
supported = get_supported_models() # HOT-RELOAD
user_model = body.get("model")
model_key = None
model_ocid = None
if isinstance(user_model, str) and user_model in supported:
model_key = user_model
model_ocid = supported[user_model]["id"]
defaults = supported[user_model].get("params", {}).copy()
elif isinstance(user_model, str) and user_model.startswith("ocid1.generativeaimodel"):
model_ocid = user_model
defaults = {}
else:
if path_model_id and path_model_id.startswith("ocid1.generativeaimodel"):
model_ocid = path_model_id
defaults = {}
elif path_model_id in supported:
model_key = path_model_id
model_ocid = supported[path_model_id]["id"]
defaults = supported[path_model_id].get("params", {}).copy()
else:
raise ValueError("Modelo ausente ou não suportado: use um dos "
f"{list(supported.keys())} ou forneça um OCID válido.")
overrides = {}
for k in [
"temperature", "top_p", "top_k", "max_tokens", "frequency_penalty", "presence_penalty",
"reasoning_effort", "verbosity", "max_completion_tokens"
]:
if k in body and k in body and body[k] is not None:
overrides[k] = body[k]
merged = {**defaults, **overrides}
return {"model_key": model_key, "model_ocid": model_ocid, "params": merged}
def to_oci_messages(openai_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
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", "")
parts: List[Dict[str, Any]] = []
if isinstance(content, list):
for p in content:
if isinstance(p, dict) and p.get("type") == "text":
txt = p.get("text", "")
if txt: parts.append({"type": "TEXT", "text": txt})
elif isinstance(p, dict) and p.get("type") == "image_url":
url = p.get("image_url", {})
if isinstance(url, dict): url = url.get("url", "")
if isinstance(url, str) and url:
data_url = ensure_data_url(url)
parts.append({"type": "IMAGE_URL", "url": data_url})
elif isinstance(p, str):
parts.append({"type": "TEXT", "text": p})
elif isinstance(content, str):
parts.append({"type": "TEXT", "text": content})
else:
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]:
payload = {"messages": messages}
if "temperature" in params: payload["temperature"] = params["temperature"]
if "top_p" in params: payload["top_p"] = params["top_p"]
if "top_k" in params: payload["top_k"] = params["top_k"]
if "frequency_penalty" in params: payload["frequency_penalty"] = params["frequency_penalty"]
if "presence_penalty" in params: payload["presence_penalty"] = params["presence_penalty"]
if "max_completion_tokens" in params:
payload["max_completion_tokens"] = params["max_completion_tokens"]
elif "max_tokens" in params:
payload["max_completion_tokens"] = params["max_tokens"]
if "reasoning_effort" in params: payload["reasoning_effort"] = params["reasoning_effort"]
if "verbosity" in params: payload["verbosity"] = params["verbosity"]
return payload
def oci_chat_invoke(region: str, compartment_id: str, model_ocid: str, oci_payload: Dict[str, Any]) -> Dict[str, Any]:
print(">>> OCI CHAT REQUEST (payload que será enviado):")
print(json.dumps(oci_payload, ensure_ascii=False, indent=2))
if TEST_MODE:
return {
"dry_run": True,
"note": "TEST_MODE=True — retorno simulado.",
"payload": oci_payload,
"output_text": "[dry-run] ambiente de teste — valide o payload impresso no console."
}
try:
endpoint = f"https://inference.generativeai.{region}.oci.oraclecloud.com"
client = oci.generative_ai_inference.GenerativeAiInferenceClient(
config=config, service_endpoint=endpoint, retry_strategy=oci.retry.NoneRetryStrategy(), timeout=(10, 240)
)
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
sdk_messages = []
for m in oci_payload["messages"]:
sdk_msg = oci.generative_ai_inference.models.Message()
sdk_msg.role = m["role"]
parts = []
for c in m["content"]:
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)
generic.messages = sdk_messages
if "temperature" in oci_payload: generic.temperature = oci_payload["temperature"]
if "top_p" in oci_payload: generic.top_p = oci_payload["top_p"]
if "top_k" in oci_payload: generic.top_k = oci_payload["top_k"]
if "frequency_penalty" in oci_payload: generic.frequency_penalty = oci_payload["frequency_penalty"]
if "presence_penalty" in oci_payload: generic.presence_penalty = oci_payload["presence_penalty"]
if "max_completion_tokens" in oci_payload: generic.max_tokens = oci_payload["max_completion_tokens"]
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
chat_response = client.chat(chat_detail)
data = chat_response.data
if hasattr(data, "chat_response") and data.chat_response and data.chat_response.choices:
choice = data.chat_response.choices[0]
text = None
if choice.message and choice.message.content:
for block in choice.message.content:
if hasattr(block, "text") and block.text:
text = block.text; break
return {"output_text": text, "raw": "sdk"}
return {"output_text": None, "raw": "unknown"}
except Exception as e:
return {"error": f"Falha ao chamar OCI: {e}"}
def to_openai_chat_response(model_label: str, content_text: str, finish_reason: str = "stop") -> Dict[str, Any]:
now = int(time.time()); rid = f"chatcmpl-{uuid.uuid4().hex[:24]}"
return {
"id": rid, "object": "chat.completion", "created": now, "model": model_label,
"choices": [{"index": 0, "message": {"role": "assistant", "content": content_text}, "finish_reason": finish_reason}],
"usage": {"prompt_tokens": None, "completion_tokens": None, "total_tokens": None}
}
def to_openai_text_response(model_label: str, content_text: str, finish_reason: str = "stop") -> Dict[str, Any]:
now = int(time.time()); rid = f"cmpl-{uuid.uuid4().hex[:24]}"
return {
"id": rid, "object": "text_completion", "created": now, "model": model_label,
"choices": [{"index": 0, "text": content_text, "finish_reason": finish_reason, "logprobs": None}],
"usage": {"prompt_tokens": None, "completion_tokens": None, "total_tokens": None}
}
def sse_chat_stream(model_label: str, full_text: str) -> Generator[str, None, None]:
rid = f"chatcmpl-{uuid.uuid4().hex[:24]}"; now = int(time.time())
first = {"id": rid, "object": "chat.completion.chunk", "created": now, "model": model_label,
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]}
yield f"data: {json.dumps(first)}\n\n"
for ch in full_text or "":
chunk = {"id": rid, "object": "chat.completion.chunk", "created": now, "model": model_label,
"choices": [{"index": 0, "delta": {"content": ch}, "finish_reason": None}]}
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
endchunk = {"id": rid, "object": "chat.completion.chunk", "created": now, "model": model_label,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
yield f"data: {json.dumps(endchunk)}\n\n"
yield "data: [DONE]\n\n"
# ===== Helpers OpenAI-v1 p/ Agents (sem session_id em URL) =====
def _messages_to_text(messages: List[Dict[str, Any]]) -> str:
"""
Concatena conteúdo textual de mensagens OpenAI-like para enviar a um GenAI Agent.
Ignora imagens/partes não-textuais. Mantém a ordem.
"""
if not isinstance(messages, list):
return ""
chunks = []
for m in messages:
content = m.get("content", "")
if isinstance(content, str):
chunks.append(content)
elif isinstance(content, list):
for p in content:
if isinstance(p, dict) and p.get("type") == "text":
t = p.get("text", "")
if t:
chunks.append(t)
elif isinstance(p, str):
chunks.append(p)
elif content:
try:
chunks.append(json.dumps(content, ensure_ascii=False))
except Exception:
pass
return "\n".join(chunks).strip()
def _coerce_to_text(val: Any) -> str:
if val is None:
return ""
if isinstance(val, str):
return val
try:
if isinstance(val, dict):
if isinstance(val.get("text"), str):
return val["text"]
if isinstance(val.get("content"), str):
return val["content"]
if isinstance(val.get("content"), dict) and isinstance(val["content"].get("text"), str):
return val["content"]["text"]
if isinstance(val.get("content"), list):
for c in val["content"]:
if isinstance(c, dict) and isinstance(c.get("text"), str):
return c["text"]
data = val.get("data")
if isinstance(data, dict):
for key in ("message", "output", "text"):
if isinstance(data.get(key), str):
return data[key]
if isinstance(data.get("content"), dict) and isinstance(data["content"].get("text"), str):
return data["content"]["text"]
if isinstance(data.get("content"), list):
for c in data["content"]:
if isinstance(c, dict) and isinstance(c.get("text"), str):
return c["text"]
return json.dumps(val, ensure_ascii=False)
except Exception:
return str(val)
def _extract_agent_text(agent_payload: Any) -> str:
"""
Extrai o texto principal de respostas de GenAI Agent em diferentes formatos,
incluindo {"role":"AGENT","content":{"text":"..."}}.
"""
if agent_payload is None:
return ""
if isinstance(agent_payload, str):
try:
maybe_json = json.loads(agent_payload)
return _extract_agent_text(maybe_json)
except Exception:
return agent_payload
if isinstance(agent_payload, dict):
candidates = [
agent_payload.get("message"),
agent_payload.get("output"),
agent_payload.get("text"),
agent_payload.get("content"),
agent_payload.get("data"),
agent_payload.get("result"),
]
for c in candidates:
if c is not None:
txt = _coerce_to_text(c)
if txt:
return txt
return _coerce_to_text(agent_payload)
return _coerce_to_text(agent_payload)
def _resolve_agent_session(region: str, agent_endpoint_id: str) -> Dict[str, Any]:
"""
SEM session_id na URL. Cria/renova sessão automaticamente com base em:
- X-Channel (opcional, default "openai-v1")
- X-Cuid (opcional). Se ausente, deriva a partir do header de auth ou gera determinístico.
"""
channel = request.headers.get("X-Channel") or "openai-v1"
cuid = request.headers.get("X-Cuid")
if not cuid:
seed = request.headers.get("Authorization") or request.headers.get("X-API-Key") or uuid.uuid4().hex
cuid = uuid.uuid5(uuid.NAMESPACE_OID, seed).hex
return session_controller(region, agent_endpoint_id, channel, cuid)
def _agent_openai_like_answer(model_label: str, agent_payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Mapeia a resposta do Agent para o formato OpenAI chat.completion.
Garante que 'choices[0].message.content' seja SEMPRE string.
"""
text = _extract_agent_text(agent_payload)
return to_openai_chat_response(model_label, text)
# ==========================
# Endpoints nativos
# ==========================
@app.route("/", methods=["GET"])
def test():
return jsonify({"test": "ok"})
@app.route("/test/<myvar>/copy", methods=["GET"])
def var_copy(myvar):
return jsonify({"myvar": myvar})
@app.route("/genai-agent/<region>/<agent_endpoint_id>/session", methods=["POST"])
def manage_session(region, agent_endpoint_id):
data = request.get_json() or {}
print(">>> /genai-agent/.../session payload recebido:")
channel = data.get("channel"); cuid = data.get("cuid")
if not all([channel, cuid]):
return jsonify({"error": "Parâmetros 'channel' e 'cuid' são obrigatórios"}), 400
response_data = session_controller(region, agent_endpoint_id, channel, cuid)
return jsonify(response_data)
@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 {}
print(">>> /genai-agent/.../chat payload recebido:")
user_message = data.get("userMessage")
if not user_message:
return jsonify({"error": "userMessage é obrigatório"}), 400
response_data = ask_agent(region, agent_endpoint_id, session_id, user_message)
return jsonify({"agentResponse": response_data})
@app.route("/genai/<region>/<compartment_id>/<model_id>/inference", methods=["POST"])
def inference(region, compartment_id, model_id):
data = request.get_json() or {}
print(">>> /inference request body:")
prompt = data.get("prompt")
if not prompt:
return jsonify({"error": "Campo 'prompt' é obrigatório."}), 400
response_data = call_inference_model(region, compartment_id, model_id, prompt)
return jsonify(response_data)
# ==========================
# Endpoints OpenAI v1 compat — CHAT (LLMs)
# ==========================
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/chat/completions", methods=["POST"])
def v1_chat_completions(region, compartment_id, path_model_id):
try:
body = request.get_json(force=True, silent=False) or {}
except Exception as e:
return jsonify({"error": f"JSON inválido: {e}"}), 400
print(">>> /v1/chat/completions body recebido:")
print(body)
try:
resolved = resolve_model_and_params(body, path_model_id)
except Exception as e:
return jsonify({"error": str(e)}), 400
model_label = body.get("model") or resolved["model_key"] or resolved["model_ocid"]
msgs = body.get("messages") or []
if not isinstance(msgs, list) or not msgs:
return jsonify({"error": "Campo 'messages' é obrigatório e deve ser uma lista."}), 400
oci_msgs = to_oci_messages(msgs)
oci_payload = build_oci_chat_payload(oci_msgs, resolved["params"])
oci_result = oci_chat_invoke(region, compartment_id, resolved["model_ocid"], oci_payload)
if isinstance(oci_result, dict):
output_text = (
oci_result.get("output_text")
or oci_result.get("generated_text")
or oci_result.get("inference_response", {}).get("output_text")
or oci_result.get("payload", {}).get("output_text")
)
else:
output_text = None
if output_text is None:
output_text = json.dumps(oci_result, ensure_ascii=False)
if body.get("stream") is True:
return Response(stream_with_context(sse_chat_stream(model_label, output_text)),
mimetype="text/event-stream")
return jsonify(to_openai_chat_response(model_label, output_text))
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/completions", methods=["POST"])
def v1_text_completions(region, compartment_id, path_model_id):
try:
body = request.get_json(force=True, silent=False) or {}
except Exception as e:
return jsonify({"error": f"JSON inválido: {e}"}), 400
print(">>> /v1/completions body recebido:")
print(body)
try:
resolved = resolve_model_and_params(body, path_model_id)
except Exception as e:
return jsonify({"error": str(e)}), 400
model_label = body.get("model") or resolved["model_key"] or resolved["model_ocid"]
prompt = body.get("prompt")
if prompt is None:
return jsonify({"error": "Campo 'prompt' é obrigatório."}), 400
prompt_text = "\n".join([str(p) for p in prompt]) if isinstance(prompt, list) else str(prompt)
msgs = [{"role": "user", "content": prompt_text}]
oci_msgs = to_oci_messages(msgs)
oci_payload = build_oci_chat_payload(oci_msgs, resolved["params"])
oci_result = oci_chat_invoke(region, compartment_id, resolved["model_ocid"], oci_payload)
if isinstance(oci_result, dict):
output_text = (
oci_result.get("output_text")
or oci_result.get("generated_text")
or oci_result.get("inference_response", {}).get("output_text")
or oci_result.get("payload", {}).get("output_text")
)
else:
output_text = None
if output_text is None:
output_text = json.dumps(oci_result, ensure_ascii=False)
if body.get("stream") is True:
return Response(stream_with_context(sse_chat_stream(model_label, output_text)),
mimetype="text/event-stream")
return jsonify(to_openai_text_response(model_label, output_text))
# ==========================
# OpenAI v1 compatibility — GenAI Agents (auto-sessão com retry 409)
# ==========================
@app.route("/genai-agent/<region>/<agent_endpoint_id>/v1/chat/completions", methods=["POST"])
def agent_v1_chat_completions_auto(region, agent_endpoint_id):
"""
OpenAI v1 compat (SEM session_id na URL): cria/renova uma sessão automaticamente.
Faz retry 1x em caso de 409 (sessão inválida/expirada).
"""
try:
body = request.get_json(force=True, silent=False) or {}
except Exception as e:
return jsonify({"error": f"JSON inválido: {e}"}), 400
messages = body.get("messages")
if not messages or not isinstance(messages, list):
return jsonify({"error": "Campo 'messages' é obrigatório e deve ser uma lista."}), 400
# cria/renova sessão automaticamente (primeira tentativa)
sess = _resolve_agent_session(region, agent_endpoint_id)
if "error" in sess:
return jsonify({"error": f"Falha ao criar sessão: {sess['error']}"}), 500
session_id = sess.get("id")
session_key = sess.get("sessionKey") or ""
# extrai texto das mensagens OpenAI-like
user_message = _messages_to_text(messages)
if not user_message:
return jsonify({"error": "Não foi possível extrair texto de 'messages'."}), 400
# 1ª chamada ao Agent
agent_resp = ask_agent(region, agent_endpoint_id, session_id, user_message)
# Se veio erro 409, invalida e tenta 1x recriar sessão
if isinstance(agent_resp, dict) and agent_resp.get("_http_status") == 409:
print(f"[agent] 409 recebido. Invalidando sessão {session_key} e recriando...")
_invalidate_session(session_key)
sess2 = _resolve_agent_session(region, agent_endpoint_id)
if "error" in sess2:
return jsonify({
"error": {
"message": f"Agent chat 409 e falha ao recriar sessão: {sess2['error']}",
"type": "agent_session_error",
"code": 409
}
}), 502
session_id = sess2.get("id")
agent_resp = ask_agent(region, agent_endpoint_id, session_id, user_message)
# Se ainda for erro HTTP (qualquer status), retornar erro amigável
if isinstance(agent_resp, dict) and agent_resp.get("_http_status"):
status = agent_resp.get("_http_status")
raw_json = agent_resp.get("_raw_json")
raw_text = agent_resp.get("_raw_text")
msg = None
if isinstance(raw_json, dict):
msg = raw_json.get("message") or raw_json.get("error") or raw_json.get("title")
if not msg:
msg = json.dumps(raw_json, ensure_ascii=False)
if not msg:
msg = raw_text or f"HTTP {status} do Agent."
return jsonify({
"error": {
"message": msg,
"type": "agent_http_error",
"code": status
}
}), 502
model_label = body.get("model") or f"agent:{agent_endpoint_id}"
# streaming
if body.get("stream") is True:
stream_text = _extract_agent_text(agent_resp)
return Response(stream_with_context(sse_chat_stream(model_label, stream_text)),
mimetype="text/event-stream")
# normal
resp = _agent_openai_like_answer(model_label, agent_resp)
resp["_agent"] = {"session_id": session_id, "reused": sess.get("reused", False)}
return jsonify(resp)
@app.route("/genai-agent/<region>/<agent_endpoint_id>/v1/models", methods=["GET"])
def agent_v1_models(region, agent_endpoint_id):
"""
Retorna a lista de modelos suportados para compatibilidade OpenAI/v1 (agents).
Estrutura idêntica à rota /genai/.../v1/models.
"""
supported = get_supported_models() # HOT-RELOAD do JSON externo
data = []
for k, v in supported.items():
data.append({
"id": k,
"object": "model",
"owned_by": "oci.genai.agent",
"ocid": v.get("id"),
"params": v.get("params", {}),
"region": region,
"agent_endpoint": agent_endpoint_id
})
return jsonify({"object": "list", "data": data})
# ==========================
# Endpoints OpenAI v1 — FILES
# ==========================
def _files_upload_handler():
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:
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})
@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("/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("/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):
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
obj_resp = object_client.get_object(namespace, BUCKET_NAME, obj)
data = obj_resp.data.content
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:
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("/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):
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
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("/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):
if "image" not in request.files:
return jsonify({"error": "Campo 'image' (multipart) é obrigatório"}), 400
_ = request.form.get("prompt", "")
base_img = request.files["image"].read()
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("/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):
if "image" not in request.files:
return jsonify({"error": "Campo 'image' (multipart) é obrigatório"}), 400
base_img = request.files["image"].read()
url = _store_image_bytes_and_return_url(base_img, "variation.png")
return jsonify({"created": int(time.time()), "data": [{"url": url, "note": "mock variation"}]})
# ==========================
# Endpoint OpenAI v1 /models (LLMs)
# ==========================
@app.route("/genai/<region>/<compartment_id>/<path_model_id>/v1/models", methods=["GET"])
def v1_models(region, compartment_id, path_model_id):
"""
Lista os modelos disponíveis (do JSON hot-reload), em formato OpenAI-like.
Ignora path_model_id (mantido apenas para compat. com o padrão de URL existente).
"""
supported = get_supported_models()
data = []
for k, v in supported.items():
data.append({
"id": k, # expõe o apelido p/ uso direto em { model: "<apelido>" }
"object": "model",
"owned_by": "oci.genai",
"ocid": v.get("id"),
"params": v.get("params", {})
})
return jsonify({"object": "list", "data": data})
# ==========================
# 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)