Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
def load_gateway_governance_config(path: str | None = None) -> dict[str, Any]:
config_path = Path(path or os.getenv("AGENT_GATEWAY_GOVERNANCE_CONFIG", "config/gateway_governance.yaml"))
if not config_path.exists():
return {}
return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger("agent_gateway.governance")
def audit_event(name: str, payload: dict[str, Any]) -> None:
safe = dict(payload)
if "message" in safe:
safe["message_len"] = len(str(safe.pop("message") or ""))
logger.info("%s %s", name, json.dumps(safe, ensure_ascii=False, default=str))

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Any
class EvaluationHooks:
def before_backend_call(self, request_payload: dict[str, Any]) -> dict[str, Any]:
return request_payload
def after_backend_call(self, response_payload: dict[str, Any]) -> dict[str, Any]:
return response_payload

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Any
class ModelPolicyError(RuntimeError):
pass
class ModelPolicyResolver:
def __init__(self, config: dict[str, Any]):
self.config = config or {}
def resolve_profile(
self,
*,
tenant_id: str,
agent_id: str | None,
operation: str,
requested_profile: str | None = None,
) -> dict[str, Any]:
profiles = self.config.get("profiles", {}) or {}
operation_profiles = self.config.get("operation_profiles", {}) or {}
profile_name = requested_profile or operation_profiles.get(operation) or "default"
profile = profiles.get(profile_name)
if not profile:
raise ModelPolicyError(f"Model profile not found: {profile_name}")
self._validate_policy(
tenant_id=tenant_id,
agent_id=agent_id,
profile_name=profile_name,
profile=profile,
)
return {
"profile": profile_name,
"provider": profile.get("provider"),
"model": profile.get("model"),
"parameters": {
k: v for k, v in profile.items()
if k not in {"provider", "model"}
},
}
def _validate_policy(
self,
*,
tenant_id: str,
agent_id: str | None,
profile_name: str,
profile: dict[str, Any],
) -> None:
policies = self.config.get("policies", {}) or {}
tenant_policies = policies.get("tenants", {}) or {}
tenant_policy = tenant_policies.get(tenant_id) or tenant_policies.get("default") or {}
allowed_profiles = tenant_policy.get("allowed_profiles")
if allowed_profiles and profile_name not in allowed_profiles:
raise ModelPolicyError(f"Profile not allowed for tenant={tenant_id}: {profile_name}")
allowed_providers = tenant_policy.get("allowed_providers")
provider = profile.get("provider")
if allowed_providers and provider not in allowed_providers:
raise ModelPolicyError(f"Provider not allowed for tenant={tenant_id}: {provider}")

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import time
from collections import defaultdict, deque
from typing import Any
class RateLimitExceeded(RuntimeError):
pass
class InMemoryRateLimiter:
def __init__(self, config: dict[str, Any]):
self.config = config or {}
self.events: dict[str, deque[float]] = defaultdict(deque)
def check(self, *, tenant_id: str, agent_id: str | None, channel: str | None) -> None:
default_limit = ((self.config.get("default") or {}).get("requests_per_minute")) or 600
agent_limits = self.config.get("agents") or {}
channel_limits = self.config.get("channels") or {}
limit = default_limit
if agent_id and agent_id in agent_limits:
limit = agent_limits[agent_id].get("requests_per_minute", limit)
if channel and channel in channel_limits:
limit = min(limit, channel_limits[channel].get("requests_per_minute", limit))
key = f"{tenant_id}:{agent_id or '*'}:{channel or '*'}"
now = time.time()
bucket = self.events[key]
while bucket and bucket[0] < now - 60:
bucket.popleft()
if len(bucket) >= int(limit):
raise RateLimitExceeded(f"Gateway rate limit exceeded for {key}: {limit}/min")
bucket.append(now)

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Any
class UsageRecorder:
def record_gateway_request(self, payload: dict[str, Any]) -> None:
return None
def record_model_policy(self, payload: dict[str, Any]) -> None:
return None
def record_backend_response(self, payload: dict[str, Any]) -> None:
return None

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from app.config.governance_loader import load_gateway_governance_config
from app.governance.audit import audit_event
from app.governance.evaluation_hooks import EvaluationHooks
from app.governance.model_policies import ModelPolicyError, ModelPolicyResolver
from app.governance.rate_limit import InMemoryRateLimiter, RateLimitExceeded
from app.governance.usage import UsageRecorder
class AgentGatewayGovernance:
def __init__(self, config: dict[str, Any] | None = None):
self.config = config if config is not None else load_gateway_governance_config()
self.model_resolver = ModelPolicyResolver((self.config.get("model_governance") or {}))
self.rate_limiter = InMemoryRateLimiter((self.config.get("rate_limits") or {}))
self.usage = UsageRecorder()
self.eval_hooks = EvaluationHooks()
def prepare_backend_request(self, gateway_request: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]:
tenant_id = gateway_request.get("tenant_id") or "default"
agent_id = gateway_request.get("agent_id")
channel = gateway_request.get("channel")
payload = gateway_request.get("payload") or {}
metadata = payload.setdefault("metadata", {})
try:
self.rate_limiter.check(tenant_id=tenant_id, agent_id=agent_id, channel=channel)
model_policy = self.model_resolver.resolve_profile(
tenant_id=tenant_id,
agent_id=agent_id,
operation=metadata.get("operation") or "agent.final_answer",
requested_profile=metadata.get("llm_profile"),
)
metadata["model_policy"] = model_policy
headers = {
"X-Agent-Gateway-Governance": "enabled",
"X-Model-Profile": str(model_policy.get("profile") or ""),
"X-Model-Provider": str(model_policy.get("provider") or ""),
"X-Model-Name": str(model_policy.get("model") or ""),
}
audit_event("agent_gateway.request.governed", {
"tenant_id": tenant_id,
"agent_id": agent_id,
"channel": channel,
"model_policy": model_policy,
"request_id": metadata.get("request_id"),
"message": payload.get("message"),
})
self.usage.record_gateway_request({
"tenant_id": tenant_id,
"agent_id": agent_id,
"channel": channel,
"metadata": metadata,
})
governed = self.eval_hooks.before_backend_call(gateway_request)
return governed, headers
except RateLimitExceeded as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
except ModelPolicyError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
def process_backend_response(self, response_payload: dict[str, Any]) -> dict[str, Any]:
response_payload = self.eval_hooks.after_backend_call(response_payload)
self.usage.record_backend_response(response_payload)
audit_event("agent_gateway.response.completed", {
"metadata": response_payload.get("metadata") if isinstance(response_payload, dict) else {},
})
return response_payload

View File

@@ -0,0 +1,328 @@
from __future__ import annotations
import logging
import time
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.global_supervisor import (
BackendClient,
BackendRegistry,
GlobalRouteRequest,
GlobalSupervisorRouter,
InMemoryGlobalSessionStore,
)
from agent_framework.llm.providers import create_llm
from agent_framework.observability.observer import AgentObserver
from agent_framework.security import install_authentication
from app.settings import settings
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_gateway")
app = FastAPI(title="Agent Gateway - Global Supervisor")
install_authentication(app, prefix="AGENT_GATEWAY_AUTH")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
registry = BackendRegistry.from_yaml(settings.BACKENDS_CONFIG_PATH)
analytics = create_analytics_publisher(settings)
observer = AgentObserver(analytics=analytics)
llm = create_llm(settings)
session_store = InMemoryGlobalSessionStore(ttl_seconds=settings.GLOBAL_SESSION_TTL_SECONDS)
router = GlobalSupervisorRouter(
registry=registry,
llm=llm if settings.GLOBAL_ROUTING_MODE in {"supervisor", "hybrid"} else None,
session_store=session_store,
mode=settings.GLOBAL_ROUTING_MODE,
keep_active_backend=settings.GLOBAL_KEEP_ACTIVE_BACKEND,
use_supervisor_on_conflict=settings.GLOBAL_USE_SUPERVISOR_ON_CONFLICT,
min_router_confidence=settings.GLOBAL_MIN_ROUTER_CONFIDENCE,
)
backend_client = BackendClient(timeout_seconds=settings.BACKEND_TIMEOUT_SECONDS)
class GatewayRequest(BaseModel):
channel: str = "web"
payload: dict = Field(default_factory=dict)
tenant_id: str | None = None
agent_id: str | None = None
backend_id: str | None = None
session_id: str | None = None
metadata: dict = Field(default_factory=dict)
def _session_id(req: GatewayRequest) -> str:
return (
req.session_id
or req.payload.get("session_id")
or req.payload.get("conversation_key")
or req.payload.get("original_session_id")
or str(uuid4())
)
def _as_backend_request(req: GatewayRequest, session_id: str) -> dict:
# Mantém o contrato do agent_template_backend: {channel, payload, agent_id, tenant_id}
payload = dict(req.payload or {})
payload.setdefault("session_id", session_id)
return {
"channel": req.channel,
"payload": payload,
"agent_id": req.agent_id,
"tenant_id": req.tenant_id or payload.get("tenant_id") or "default",
}
@app.middleware("http")
async def noc_middleware(request: Request, call_next):
started = time.time()
try:
response = await call_next(request)
await observer.emit_noc("006", {"component": "agent_gateway", "path": request.url.path, "status_code": response.status_code, "duration_ms": int((time.time() - started) * 1000)})
return response
except Exception as exc:
await observer.emit_noc("005", {"component": "agent_gateway", "path": request.url.path, "error": str(exc), "duration_ms": int((time.time() - started) * 1000)})
raise
@app.get("/health")
async def health():
return {
"status": "ok",
"app": settings.APP_NAME,
"routing_mode": settings.GLOBAL_ROUTING_MODE,
"backends": [b.backend_id for b in registry.list()],
"llm_provider": settings.LLM_PROVIDER,
}
@app.get("/backends")
async def backends():
return registry.as_dict()
@app.get("/backends/health")
async def backends_health():
results = []
for backend in registry.list():
results.append(await backend_client.health(backend))
return {"results": results}
@app.post("/debug/route")
async def debug_route(req: GatewayRequest):
session_id = _session_id(req)
route_req = GlobalRouteRequest(
channel=req.channel,
payload=req.payload,
tenant_id=req.tenant_id,
session_id=session_id,
force_backend=req.backend_id,
metadata=req.metadata,
)
decision = await router.route(route_req)
return decision.model_dump(mode="json")
@app.get("/debug/sessions")
async def debug_sessions():
return await session_store.dump()
@app.post("/gateway/message")
async def gateway_message(req: GatewayRequest):
started = time.time()
session_id = _session_id(req)
tenant_id = req.tenant_id or req.payload.get("tenant_id") or "default"
await observer.emit_ic("GLOBAL_GATEWAY_RECEIVED", {"session_id": session_id, "tenant_id": tenant_id, "channel": req.channel})
route_req = GlobalRouteRequest(
channel=req.channel,
payload=req.payload,
tenant_id=tenant_id,
session_id=session_id,
force_backend=req.backend_id,
metadata=req.metadata,
)
decision = await router.route(route_req)
backend = registry.get(decision.backend_id)
await observer.emit_ic("GLOBAL_BACKEND_SELECTED", {"session_id": session_id, "backend_id": backend.backend_id, "confidence": decision.confidence, "reason": decision.reason})
try:
result = await backend_client.call_message(backend, _as_backend_request(req, session_id), decision)
except Exception as exc:
await observer.emit_noc("005", {"component": "agent_gateway", "backend_id": backend.backend_id, "session_id": session_id, "error": str(exc)})
raise HTTPException(status_code=502, detail={"message": "Falha ao chamar backend selecionado", "backend_id": backend.backend_id, "error": str(exc)})
# Handoff opcional: backend pode pedir troca via metadata.handover_backend.
response = result.response
backend_session_id = (
response.get("session_id")
or response.get("metadata", {}).get("conversation_key")
)
if backend_session_id:
session_data = await session_store.set_active_backend(
session_id=session_id,
backend_id=backend.backend_id,
tenant_id=tenant_id,
backend_session_id=backend_session_id,
)
response["session_id"] = session_id
metadata = response.get("metadata") or {}
metadata["backend_session_id"] = backend_session_id
metadata["global_session_id"] = session_id
response["metadata"] = metadata
response["session_id"] = session_id
metadata = response.get("metadata") or {}
handover_backend = metadata.get("handover_backend") or metadata.get("handover_to_backend")
if handover_backend and handover_backend in registry.backends and handover_backend != backend.backend_id:
await observer.emit_ic("GLOBAL_BACKEND_HANDOVER", {"session_id": session_id, "from_backend": backend.backend_id, "to_backend": handover_backend})
forced = GatewayRequest(**req.model_dump())
forced.backend_id = handover_backend
forced.payload = {**forced.payload, "handover_from_backend": backend.backend_id}
return await gateway_message(forced)
await observer.emit_ic("GLOBAL_GATEWAY_COMPLETED", {"session_id": session_id, "backend_id": backend.backend_id, "elapsed_ms": int((time.time() - started) * 1000)})
metadata = dict(metadata)
metadata["global_route_decision"] = decision.model_dump(mode="json")
metadata["selected_backend"] = backend.backend_id
metadata["backend_elapsed_ms"] = result.elapsed_ms
response["metadata"] = metadata
return response
@app.post("/gateway/message/sse")
async def gateway_message_sse(req: GatewayRequest):
# Para simplificar o contrato, primeiro roteia via gateway e delega ao endpoint SSE do backend.
# O frontend pode continuar usando /gateway/events/{session_id} diretamente no backend escolhido,
# ou evoluir para um proxy SSE no gateway.
return await gateway_message(req)
from fastapi.responses import StreamingResponse
import httpx
import asyncio
@app.get("/gateway/events/{session_id:path}")
async def gateway_events(session_id: str):
async def stream():
yield (
"event: connected\n"
f'data: {{"session_id":"{session_id}","component":"agent_gateway"}}\n\n'
)
session_data = await session_store.get(session_id)
while not session_data:
yield (
"event: waiting\n"
f'data: {{"session_id":"{session_id}"}}\n\n'
)
await asyncio.sleep(1)
session_data = await session_store.get(session_id)
logger.error("SESSION_DATA SSE = %s", session_data)
backend_id = session_data.active_backend
backend_session_id = session_id
if not backend_id:
yield (
"event: error\n"
f'data: {{"message":"Sessão encontrada sem active_backend",'
f'"session_id":"{session_id}"}}\n\n'
)
return
backend = registry.get(backend_id)
backend_base_url = (
getattr(backend, "base_url", None)
or getattr(backend, "url", None)
or getattr(backend, "endpoint", None)
or getattr(backend, "base_endpoint", None)
)
if not backend_base_url:
yield (
"event: error\n"
f'data: {{"message":"Backend sem URL configurada",'
f'"backend_id":"{backend_id}"}}\n\n'
)
return
backend_sse_url = (
f"{backend_base_url.rstrip('/')}/gateway/events/{backend_session_id}"
)
yield (
"event: backend.selected\n"
f'data: {{"session_id":"{session_id}",'
f'"backend_id":"{backend_id}",'
f'"backend_session_id":"{backend_session_id}",'
f'"backend_sse_url":"{backend_sse_url}"}}\n\n'
)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", backend_sse_url) as response:
content_type = response.headers.get("content-type", "")
if response.status_code != 200:
body = await response.aread()
yield (
"event: error\n"
f'data: {{"message":"Backend SSE retornou erro",'
f'"status_code":{response.status_code},'
f'"content_type":"{content_type}",'
f'"body":{body.decode("utf-8", errors="replace")!r}}}\n\n'
)
return
if "text/event-stream" not in content_type:
body = await response.aread()
yield (
"event: error\n"
f'data: {{"message":"Backend SSE não retornou text/event-stream",'
f'"status_code":{response.status_code},'
f'"content_type":"{content_type}",'
f'"body":{body.decode("utf-8", errors="replace")!r}}}\n\n'
)
return
async for chunk in response.aiter_text():
if chunk:
yield chunk
except Exception as exc:
logger.exception("Erro no proxy SSE do gateway")
yield (
"event: error\n"
f'data: {{"message":"Erro no proxy SSE do gateway",'
f'"error":"{str(exc)}"}}\n\n'
)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from app.governance_middleware import AgentGatewayGovernance
router = APIRouter()
governance = AgentGatewayGovernance()
@router.post("/gateway/message/governed")
async def governed_gateway_message(request: Request):
"""Example governed proxy route.
Use as reference to patch the existing /gateway/message handler.
"""
body: dict[str, Any] = await request.json()
backend_url = os.getenv("DEFAULT_AGENT_BACKEND_URL", "http://localhost:8000")
governed_body, headers = governance.prepare_backend_request(body)
try:
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(
f"{backend_url.rstrip('/')}/gateway/message",
json=governed_body,
headers=headers,
)
resp.raise_for_status()
data = resp.json()
return governance.process_backend_response(data)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=exc.response.status_code, detail=exc.response.text) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
class GatewaySettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
APP_NAME: str = "agent-gateway-global-supervisor"
APP_ENV: str = "local"
LOG_LEVEL: str = "INFO"
API_HOST: str = "0.0.0.0"
API_PORT: int = 8010
CORS_ORIGINS: str = "http://localhost:5173"
BACKENDS_CONFIG_PATH: str = "./config/backends.yaml"
GLOBAL_ROUTING_MODE: Literal["router", "supervisor", "hybrid"] = "hybrid"
GLOBAL_KEEP_ACTIVE_BACKEND: bool = True
GLOBAL_USE_SUPERVISOR_ON_CONFLICT: bool = True
GLOBAL_MIN_ROUTER_CONFIDENCE: float = 0.55
GLOBAL_SESSION_TTL_SECONDS: int = 3600
BACKEND_TIMEOUT_SECONDS: float = 120.0
# Reusa o provider do framework para o supervisor LLM.
LLM_PROVIDER: Literal["mock", "oci_openai", "oci_sdk", "openai_compatible"] = "mock"
LLM_TEMPERATURE: float = 0.0
LLM_MAX_TOKENS: int = 700
LLM_TIMEOUT_SECONDS: int = 60
OCI_GENAI_BASE_URL: str = "https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com/openai/v1"
OCI_GENAI_MODEL: str = "openai.gpt-4.1"
OCI_GENAI_API_KEY: str | None = None
ENABLE_LANGFUSE: bool = False
LANGFUSE_PUBLIC_KEY: str | None = None
LANGFUSE_SECRET_KEY: str | None = None
LANGFUSE_HOST: str = "https://cloud.langfuse.com"
MODEL_PRICES_JSON: str | None = None
USD_BRL_RATE: str = "5.0"
# Analytics/Observer do próprio gateway.
ENABLE_ANALYTICS: bool = False
ANALYTICS_PROVIDERS: str = "oci_streaming"
GCP_PUBSUB_TOPIC_PATH: str | None = None
AGENT_PUBSUB_TOPIC: str | None = None
GCP_PROJECT_ID: str | None = None
GCP_PUBSUB_TOPIC: str | None = None
GCP_PUBSUB_TIMEOUT_SECONDS: float = 30.0
ANALYTICS_FAIL_SILENT: bool = True
ENABLE_OCI_STREAMING: bool = False
OCI_STREAM_ENDPOINT: str | None = None
OCI_STREAM_OCID: str | None = None
OCI_STREAM_PARTITION_KEY: str = "agent-gateway-events"
@lru_cache
def get_settings() -> GatewaySettings:
return GatewaySettings()
settings = get_settings()