First commit

This commit is contained in:
2026-06-19 22:17:09 -03:00
commit 239203ee2a
533 changed files with 75195 additions and 0 deletions

364
apps/agent_frontend/app.js Normal file
View File

@@ -0,0 +1,364 @@
const chat=document.getElementById('chat');
const form=document.getElementById('form');
let eventSource=null;
let currentSessionId = null;
function add(role,text){
const d=document.createElement('div');
d.className='msg '+role;
d.textContent=text;
chat.appendChild(d);
chat.scrollTop=chat.scrollHeight;
}
function status(text){const el=document.getElementById('status'); if(el) el.textContent=text;}
function val(id){return (document.getElementById(id)?.value || '').trim();}
function uuid(){return crypto.randomUUID();}
function buildBusinessContext(session, messageId){
return {
customer_key: val('customerKey') || null,
contract_key: val('contractKey') || null,
interaction_key: val('interactionKey') || messageId,
account_key: val('accountKey') || null,
resource_key: val('resourceKey') || null,
session_key: session || null,
metadata: {frontend: 'agent_frontend', version: 'business-context-v2'}
};
}
function syncDomainAliases(payload, businessContext){
const agent=val('agent');
if(agent === 'retail_orders'){
payload.customer_id = businessContext.customer_key;
payload.order_id = businessContext.contract_key;
} else {
payload.msisdn = businessContext.customer_key;
payload.invoice_id = businessContext.contract_key;
payload.ura_call_id = businessContext.interaction_key;
payload.asset_id = businessContext.resource_key;
}
}
function adicionarMensagem(role, text) {
const chat =
document.getElementById("chat") ||
document.getElementById("messages") ||
document.querySelector(".chat") ||
document.querySelector(".messages") ||
document.querySelector("[data-chat]");
if (!chat) {
console.error("Não encontrei o container do chat no HTML.");
console.log("Mensagem que seria exibida:", role, text);
return;
}
const div = document.createElement("div");
if (role === "user") {
div.className = "msg user chat-bubble--user";
} else {
div.className = "msg assistant chat-bubble--agent";
}
div.textContent = text || "";
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
}
function abrirSSE(sessionId) {
if (!sessionId) {
console.error("Não vou abrir SSE sem sessionId.");
return;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
const url = `${backend}/gateway/events/${sessionId}`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
console.log("SSE OPEN");
};
eventSource.onerror = (err) => {
console.error("SSE ERROR:", err);
};
const eventos = [
"connected",
"waiting",
"backend.selected",
"flow.start",
"workflow.started",
"message.responded",
"workflow.completed",
"flow.end",
"error"
];
for (const nome of eventos) {
eventSource.addEventListener(nome, (event) => {
if (nome === "message.responded") {
try {
const data = JSON.parse(event.data);
const text =
data.text ||
data.message ||
data.response ||
data.content ||
data.output ||
event.data;
adicionarMensagem("assistant", text);
} catch {
adicionarMensagem("assistant", event.data);
}
}
if (nome === "error") {
adicionarMensagem("assistant", `Erro SSE: ${event.data}`);
}
});
}
}
function normalizeSessionId(value) {
if (!value) return uuid();
const parts = value.split(":");
return parts[parts.length - 1]; // mantém só o UUID final
}
function connectSSE(backend, sessionId) {
if (!sessionId) {
console.warn("SSE não aberto: sessionId ausente.");
return;
}
if (!backend) {
console.warn("SSE não aberto: backend ausente.");
return;
}
if (eventSource) {
console.log("Fechando SSE anterior:", eventSource.url);
eventSource.close();
eventSource = null;
}
const url = `${backend.replace(/\/$/, "")}/gateway/events/${encodeURIComponent(sessionId)}`;
console.log("Abrindo SSE:", url);
eventSource = new EventSource(url);
eventSource._sessionId = sessionId;
eventSource.onopen = () => {
console.log("SSE OPEN:", url);
status("SSE conectado");
};
eventSource.onerror = (err) => {
console.error("SSE ERROR raw:", err);
console.error("SSE readyState:", eventSource?.readyState);
console.error("SSE url:", eventSource?.url);
if (eventSource?.readyState === EventSource.CONNECTING) {
status("SSE aguardando/reconectando");
return;
}
if (eventSource?.readyState === EventSource.CLOSED) {
status("SSE fechado");
return;
}
status("SSE com erro");
};
eventSource.addEventListener("connected", (event) => {
console.log("SSE connected:", event.data);
status("SSE conectado");
});
eventSource.addEventListener("waiting", (event) => {
console.log("SSE waiting:", event.data);
status("SSE aguardando backend");
});
eventSource.addEventListener("backend.selected", (event) => {
console.log("SSE backend.selected:", event.data);
status("Backend selecionado");
});
eventSource.addEventListener("flow.start", (event) => {
console.log("SSE flow.start:", event.data);
status("Fluxo iniciado");
});
eventSource.addEventListener("workflow.started", (event) => {
console.log("SSE workflow.started:", event.data);
status("Workflow em execução");
});
eventSource.addEventListener("session.upserted", (event) => {
try {
const data = JSON.parse(event.data);
if (data.business_context) {
console.debug("business_context", data.business_context);
}
} catch (e) {
console.warn("Não consegui interpretar session.upserted:", event.data);
}
});
eventSource.addEventListener("message.responded", (event) => {
try {
const data = JSON.parse(event.data);
const text =
data.text ||
data.message ||
data.response ||
data.content ||
data.output ||
event.data;
if (text) {
add("assistant", text);
}
if (data.metadata?.business_context) {
console.debug("metadata.business_context", data.metadata.business_context);
}
status("Resposta recebida");
} catch (e) {
add("assistant", event.data);
status("Resposta recebida");
}
});
eventSource.addEventListener("workflow.completed", (event) => {
console.log("SSE workflow.completed:", event.data);
status("Workflow concluído");
});
eventSource.addEventListener("flow.end", (event) => {
console.log("SSE flow.end:", event.data);
status("Fluxo finalizado");
});
// Use um nome diferente de "error" para erro enviado pelo servidor.
// "error" é reservado/conflituoso com erro nativo do EventSource.
eventSource.addEventListener("server.error", (event) => {
console.error("SSE server.error:", event.data);
add("assistant", `Erro SSE: ${event.data || "erro informado pelo servidor"}`);
status("Erro no fluxo SSE");
});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('message');
const text = input.value.trim();
if (!text) return;
adicionarMensagem('user', text);
input.value = '';
const backend = val('backend').replace(/\/$/, '');
const channel = val('channel');
// const session = val('session') || uuid();
const session = normalizeSessionId(val('session'));
const messageId = uuid();
const tenantId = val('tenant') || 'default';
const agentId = val('agent') || 'telecom_contas';
document.getElementById('session').value = session;
const businessContext = buildBusinessContext(session, messageId);
const commonContext = {
channel_id: 'browser',
tenant_id: tenantId,
agent_id: agentId,
business_context: businessContext
};
const payload = channel === 'voice'
? {
transcript: text,
session_id: session,
ani: businessContext.customer_key,
message_id: messageId,
tenant_id: tenantId,
agent_id: agentId,
context: commonContext
}
: {
message: text,
text: text,
session_id: session,
user_id: businessContext.customer_key || 'web-user',
message_id: messageId,
tenant_id: tenantId,
agent_id: agentId,
context: commonContext
};
syncDomainAliases(payload, businessContext);
try {
status('Enviando mensagem');
const res = await fetch(`${backend}/gateway/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
channel,
tenant_id: tenantId,
agent_id: agentId,
payload
})
});
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
const data = await res.json();
const returnedSessionId = data.session_id || session;
currentSessionId = returnedSessionId;
document.getElementById('session').value = returnedSessionId;
const resposta =
data.text ||
data.speak ||
data.message ||
data.response ||
data.content ||
data.output ||
JSON.stringify(data);
adicionarMensagem('assistant', resposta);
status('Resposta recebida');
} catch (err) {
adicionarMensagem('assistant', `Erro ao chamar backend: ${err.message}`);
status('Erro de conexão');
}
});

View File

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI Agent Frontend</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="app">
<header>
<h1>AI Agent Platform</h1>
<p>Frontend independente com chaves de conversa propagadas até o framework e MCP Server.</p>
</header>
<section class="config">
<label>Backend URL <input id="backend" value="http://localhost:8000" /></label>
<label>Canal
<select id="channel"><option value="web">web</option><option value="whatsapp">whatsapp</option><option value="voice">voice</option></select>
</label>
<label>Tenant <input id="tenant" value="default" /></label>
<label>Agent
<select id="agent">
<option value="telecom_contas">telecom_contas</option>
<option value="retail_orders">retail_orders</option>
</select>
</label>
<label>Session ID <input id="session" placeholder="gerado automaticamente" /></label>
<label><input id="useSse" type="checkbox" checked /> Usar SSE</label>
<span id="status">SSE aguardando</span>
</section>
<section class="config identity">
<label>customer_key <input id="customerKey" value="11999999999" /></label>
<label>contract_key <input id="contractKey" value="3000131180" /></label>
<label>interaction_key <input id="interactionKey" placeholder="URA/call/message id" /></label>
<label>resource_key <input id="resourceKey" placeholder="asset/product/resource" /></label>
<label>account_key <input id="accountKey" placeholder="billing account/customer account" /></label>
</section>
<section id="chat" class="chat"></section>
<form id="form">
<input id="message" placeholder="Digite sua mensagem..." autocomplete="off" />
<button>Enviar</button>
</form>
</main>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
body{font-family:system-ui,Arial,sans-serif;background:#f6f7fb;margin:0}.app{max-width:900px;margin:0 auto;padding:24px}header{margin-bottom:16px}.config{display:grid;grid-template-columns:1fr 160px 1fr;gap:12px;margin-bottom:16px}.config input,.config select,#message{width:100%;padding:10px;border:1px solid #ccc;border-radius:8px}.chat{background:#fff;border:1px solid #ddd;border-radius:12px;min-height:420px;padding:16px;overflow:auto}.msg{padding:10px 12px;border-radius:10px;margin:8px 0;max-width:75%}.user{background:#e8f0ff;margin-left:auto}.assistant{background:#f0f0f0}form{display:flex;gap:8px;margin-top:12px}button{padding:10px 16px;border:0;border-radius:8px;background:#111;color:#fff;cursor:pointer}

View File

@@ -0,0 +1,35 @@
APP_NAME=agent-gateway-global-supervisor
APP_ENV=local
LOG_LEVEL=INFO
API_HOST=0.0.0.0
API_PORT=8010
CORS_ORIGINS=http://localhost:5173
BACKENDS_CONFIG_PATH=./config/backends.yaml
GLOBAL_ROUTING_MODE=hybrid
GLOBAL_KEEP_ACTIVE_BACKEND=true
GLOBAL_USE_SUPERVISOR_ON_CONFLICT=true
GLOBAL_MIN_ROUTER_CONFIDENCE=0.55
GLOBAL_SESSION_TTL_SECONDS=3600
BACKEND_TIMEOUT_SECONDS=120
# Para o supervisor global. Em dev, use mock; em produção, use oci_openai/openai_compatible.
LLM_PROVIDER=mock
LLM_TEMPERATURE=0
LLM_MAX_TOKENS=700
LLM_TIMEOUT_SECONDS=60
OCI_GENAI_BASE_URL=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com/openai/v1
OCI_GENAI_MODEL=openai.gpt-4.1
OCI_GENAI_API_KEY=
# Analytics do próprio gateway
ENABLE_ANALYTICS=false
ANALYTICS_PROVIDERS=oci_streaming,pubsub
GCP_PUBSUB_TOPIC_PATH=
AGENT_PUBSUB_TOPIC=
GCP_PROJECT_ID=
GCP_PUBSUB_TOPIC=
ENABLE_OCI_STREAMING=false
OCI_STREAM_ENDPOINT=
OCI_STREAM_OCID=
OCI_STREAM_PARTITION_KEY=agent-gateway-events

View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY agent_framework /agent_framework
COPY agent_gateway /app
RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]

View File

@@ -0,0 +1,73 @@
# Agent Gateway — Global Supervisor
Este serviço roteia uma mesma conversa entre vários backends de agentes independentes, todos usando o `agent_framework`.
## Papel do Gateway
```text
Frontend
Agent Gateway / Global Supervisor
Backend Contas | Backend Ofertas | Backend Suporte | ...
```
O Gateway não executa a lógica de negócio dos agentes. Ele decide **qual backend** deve receber a mensagem e encaminha a requisição para o endpoint `/gateway/message` do backend escolhido.
## Modos de roteamento
- `router`: usa regras, keywords e domínios do `config/backends.yaml`.
- `supervisor`: usa LLM para escolher o backend.
- `hybrid`: mantém o backend ativo quando a mensagem parece continuação; usa regras; chama LLM em ambiguidade.
## Como subir localmente
```bash
cd agent_gateway
cp .env.example .env
export PYTHONPATH=../agent_framework/src:.
uvicorn app.main:app --host 0.0.0.0 --port 8010 --reload
```
Suba seus backends nas portas definidas em `config/backends.yaml`.
## Teste de rota
```bash
curl -X POST http://localhost:8010/debug/route \
-H 'content-type: application/json' \
-d '{"channel":"web","payload":{"text":"Minha fatura veio alta","session_id":"s1"}}'
```
## Enviar mensagem
```bash
curl -X POST http://localhost:8010/gateway/message \
-H 'content-type: application/json' \
-d '{"channel":"web","payload":{"text":"Minha fatura veio alta","session_id":"s1"}}'
```
## Handoff entre backends
Um backend pode solicitar troca retornando no `metadata`:
```json
{
"metadata": {
"handover_backend": "ofertas"
}
}
```
O Gateway chamará automaticamente o novo backend.
## IC/NOC
O Gateway emite eventos de observabilidade:
- `IC.GLOBAL_GATEWAY_RECEIVED`
- `IC.GLOBAL_BACKEND_SELECTED`
- `IC.GLOBAL_BACKEND_HANDOVER`
- `IC.GLOBAL_GATEWAY_COMPLETED`
- `NOC.005` em falhas
- `NOC.006` em conclusão HTTP

View File

@@ -0,0 +1,326 @@
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 app.settings import settings
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_gateway")
app = FastAPI(title="Agent Gateway - Global Supervisor")
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,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()

View File

@@ -0,0 +1,38 @@
default_backend: contas
backends:
contas:
url: http://localhost:8001
description: Backend responsável por faturas, contas, pagamentos, consumo, segunda via e contestação.
domains: [contas, fatura, pagamento, consumo, contestacao]
keywords: [fatura, conta, boleto, pagamento, consumo, segunda via, contestar, contestação, valor, cobrança]
examples:
- Quero consultar minha fatura
- Minha conta veio alta
- Preciso da segunda via do boleto
priority: 10
default_agent_id: telecom_contas
ofertas:
url: http://localhost:8002
description: Backend responsável por ofertas, planos, upgrades, retenção e contratação.
domains: [ofertas, planos, retenção, contratação]
keywords: [oferta, plano, contratar, upgrade, desconto, promoção, pacote, retenção, cancelar serviço]
examples:
- Quero trocar meu plano
- Tem alguma oferta para mim?
- Quero cancelar um serviço
priority: 20
default_agent_id: telecom_ofertas
suporte:
url: http://localhost:8003
description: Backend responsável por suporte técnico, falhas, rede, internet e atendimento operacional.
domains: [suporte, técnico, rede, internet]
keywords: [internet, sinal, rede, suporte, técnico, problema, falha, sem conexão, modem]
examples:
- Minha internet está lenta
- Estou sem sinal
- Preciso de suporte técnico
priority: 30
default_agent_id: telecom_suporte

View File

@@ -0,0 +1,31 @@
# Arquitetura — Global Supervisor
```text
Usuário / Frontend
┌───────────────────────────────┐
│ Agent Gateway │
│ Global Supervisor │
│ │
│ - Router por regras │
│ - Supervisor via LLM │
│ - Híbrido stateful │
│ - Handoff entre backends │
└───────────────┬───────────────┘
┌─────────┼─────────┬────────────┐
▼ ▼ ▼ ▼
Backend Backend Backend Backend
Contas Ofertas Suporte Cobrança
```
Cada backend continua sendo um projeto independente, com seus próprios agentes, prompts, MCPs e deploy, mas todos usam a mesma biblioteca `agent_framework`.
## Estado global
O Gateway mantém um `active_backend` por `session_id`. No modo `hybrid`, mensagens curtas como "e esse valor?" continuam no backend ativo sem chamar LLM.
## Memória compartilhada
Para produção, configure os backends para usar o mesmo Session/Memory/Checkpoint Repository, preferencialmente Autonomous DB, Oracle, MongoDB ou Redis + DB.

View File

@@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.8.0
pydantic-settings>=2.4.0
PyYAML>=6.0.2
httpx>=0.27.0
python-dotenv>=1.0.1

View File

@@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
COPY apps/ai_gateway/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY apps/ai_gateway /app
EXPOSE 9100
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9100"]

18
apps/ai_gateway/README.md Normal file
View File

@@ -0,0 +1,18 @@
# AI Gateway
Camada desacoplada para abstração, roteamento, controle e governança de chamadas LLM.
Este componente não substitui o Agent Runtime. Ele centraliza políticas de modelo, seleção de provider, fallback, telemetria e controles corporativos.
## Rotas
- `GET /health`
- `GET /models`
- `POST /v1/chat/completions`
## Execução local
```bash
cd apps/ai_gateway
uvicorn app.main:app --host 0.0.0.0 --port 9100 --reload
```

View File

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import os
from typing import Any, Literal
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
class Message(BaseModel):
role: Literal["system", "user", "assistant", "tool"]
content: str | list[Any] | None = None
class ChatCompletionRequest(BaseModel):
model: str | None = None
messages: list[Message]
temperature: float | None = None
max_tokens: int | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ModelRoute(BaseModel):
provider: str
model: str
base_url: str | None = None
app = FastAPI(title="Agent Framework OCI - AI Gateway", version="0.1.0")
def resolve_route(requested_model: str | None, metadata: dict[str, Any]) -> ModelRoute:
provider = metadata.get("provider") or os.getenv("AI_GATEWAY_DEFAULT_PROVIDER", "oci_openai")
model = requested_model or metadata.get("profile_model") or os.getenv("AI_GATEWAY_DEFAULT_MODEL", "openai.gpt-4.1")
base_url = os.getenv("AI_GATEWAY_OPENAI_COMPAT_BASE_URL")
return ModelRoute(provider=provider, model=model, base_url=base_url)
@app.get("/health")
def health() -> dict[str, Any]:
return {"status": "ok", "component": "ai_gateway"}
@app.get("/models")
def models() -> dict[str, Any]:
return {
"default_provider": os.getenv("AI_GATEWAY_DEFAULT_PROVIDER", "oci_openai"),
"default_model": os.getenv("AI_GATEWAY_DEFAULT_MODEL", "openai.gpt-4.1"),
"purpose": "model routing, policy control and LLM abstraction",
}
@app.post("/v1/chat/completions")
async def chat_completions(payload: ChatCompletionRequest) -> dict[str, Any]:
route = resolve_route(payload.model, payload.metadata)
# Safe default: when no upstream is configured, return route decision only.
# Production deployments should configure AI_GATEWAY_OPENAI_COMPAT_BASE_URL and credentials.
if not route.base_url:
return {
"gateway": "ai_gateway",
"mode": "dry_run",
"route": route.model_dump(),
"message": "No upstream base URL configured. Set AI_GATEWAY_OPENAI_COMPAT_BASE_URL to proxy requests.",
}
api_key = os.getenv("AI_GATEWAY_API_KEY") or os.getenv("OCI_GENAI_API_KEY") or os.getenv("OPENAI_API_KEY")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
body = payload.model_dump(exclude_none=True)
body["model"] = route.model
try:
async with httpx.AsyncClient(timeout=float(os.getenv("AI_GATEWAY_TIMEOUT_SECONDS", "60"))) as client:
resp = await client.post(f"{route.base_url.rstrip('/')}/chat/completions", json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
data.setdefault("gateway", "ai_gateway")
data.setdefault("route", route.model_dump())
return data
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"AI upstream request failed: {exc}") from exc

View File

@@ -0,0 +1,5 @@
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2
httpx>=0.27
PyYAML>=6

View File

@@ -0,0 +1,17 @@
APP_NAME=external-channel-gateway
LOG_LEVEL=INFO
API_HOST=0.0.0.0
API_PORT=7000
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# adapter = receive channel payloads at /channels/* and convert to GatewayRequest.
# proxy = receive only GatewayRequest at /gateway/message and forward it.
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
AGENT_FRAMEWORK_BASE_URL=http://localhost:8000
AGENT_FRAMEWORK_GATEWAY_PATH=/gateway/message
DEFAULT_TENANT_ID=default
DEFAULT_AGENT_ID=telecom_contas
REQUEST_TIMEOUT_SECONDS=120
# Optional: shared token added to calls from channel_gateway to backend.
INTERNAL_GATEWAY_TOKEN=

View File

@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY config ./config
ENV PYTHONPATH=/app
EXPOSE 7000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7000"]

View File

@@ -0,0 +1,112 @@
# External Channel Gateway
This service is a separate Channel Gateway that sits in front of the Agent Framework backend.
It has its own runtime mode, independent from the backend input mode.
## Runtime modes
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
`adapter` means this service receives channel-specific payloads and translates them into `GatewayRequest` before calling the Agent Framework backend.
```env
CHANNEL_GATEWAY_RUNTIME_MODE=proxy
```
`proxy` means this service accepts only an already-built `GatewayRequest` at `/gateway/message` and forwards it after validation.
## Recommended enterprise setup
In `channel_gateway/.env`:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
AGENT_FRAMEWORK_BASE_URL=http://localhost:8000
DEFAULT_TENANT_ID=default
DEFAULT_AGENT_ID=telecom_contas
```
In `agent_template_backend/.env`:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
This means:
```text
channel_gateway:7000 = understands channel payloads and builds GatewayRequest
backend:8000 = accepts only GatewayRequest and does not parse native channel payloads
```
## Run
```bash
cd channel_gateway
cp .env.example .env
uvicorn app.main:app --host 0.0.0.0 --port 7000
```
## Test web adapter endpoint
```bash
curl -s -X POST "http://localhost:7000/channels/web/message" \
-H "Content-Type: application/json" \
-d '{
"message": "Quero consultar minha fatura",
"session_id": "external-gw-test-001",
"user_id": "user-external-001",
"message_id": "msg-external-001",
"customer_key": "11999999999",
"contract_key": "3000131180",
"interaction_key": "301953872",
"session_key": "external-gw-test-001"
}' | jq
```
## Test proxy mode
Set:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=proxy
```
Then call:
```bash
curl -s -X POST "http://localhost:7000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"channel": "web",
"tenant_id": "default",
"agent_id": "telecom_contas",
"payload": {
"message": "Quero consultar minha fatura",
"session_id": "proxy-test-001"
}
}' | jq
```
## Important distinction
Do not use `CHANNEL_GATEWAY_MODE=external` to mean “this service is external”.
Use:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
for the external gateway service that owns adapters.
Use:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
in the Agent Framework backend when the backend must accept only normalized `GatewayRequest` payloads.

View File

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Protocol
from app.schemas import GatewayRequest
class ChannelAdapter(Protocol):
name: str
async def to_gateway_request(self, payload) -> GatewayRequest: ...

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, VoiceTranscript
from app.settings import settings
class VoiceAdapter:
name = 'voice'
async def to_gateway_request(self, payload: VoiceTranscript) -> GatewayRequest:
session_id = payload.session_id or payload.call_id
message_id = payload.message_id or (f'{payload.call_id}-turn-1' if payload.call_id else None)
bc = BusinessContext(
customer_key=payload.customer_key or payload.caller,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key or payload.call_id,
session_key=session_id,
metadata={
'source_channel': 'voice',
'confidence': payload.confidence,
'language': payload.language,
**(payload.metadata or {}),
},
)
data = {
'message': payload.transcript,
'session_id': session_id,
'user_id': payload.caller,
'message_id': message_id,
'customer_key': bc.customer_key,
'contract_key': bc.contract_key,
'interaction_key': bc.interaction_key,
'session_key': bc.session_key,
'business_context': bc.model_dump(exclude_none=True),
'metadata': {
**(payload.metadata or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'voice',
'call_id': payload.call_id,
'confidence': payload.confidence,
'language': payload.language,
'contract_version': 'gateway-request-v1',
},
}
return GatewayRequest(channel='voice', tenant_id=settings.DEFAULT_TENANT_ID, agent_id=settings.DEFAULT_AGENT_ID, payload=data)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, WebMessage
from app.settings import settings
class WebAdapter:
name = 'web'
async def to_gateway_request(self, payload: WebMessage) -> GatewayRequest:
bc = payload.business_context or BusinessContext(
customer_key=payload.customer_key,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key,
account_key=payload.account_key,
resource_key=payload.resource_key,
session_key=payload.session_key or payload.session_id,
metadata={'source_channel': 'web', **(payload.metadata or {})},
)
data = payload.model_dump(exclude_none=True)
data['business_context'] = bc.model_dump(exclude_none=True)
data.setdefault('session_key', payload.session_key or payload.session_id)
data.setdefault('metadata', {})
data['metadata'] = {
**(data.get('metadata') or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'web',
'contract_version': 'gateway-request-v1',
}
return GatewayRequest(
channel='web',
tenant_id=settings.DEFAULT_TENANT_ID,
agent_id=settings.DEFAULT_AGENT_ID,
payload=data,
)

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, WhatsAppWebhook
from app.settings import settings
class WhatsAppAdapter:
name = 'whatsapp'
async def to_gateway_request(self, payload: WhatsAppWebhook) -> GatewayRequest:
user_id = payload.wa_id or payload.from_
text = payload.message or payload.text or payload.interactive_title or payload.interactive_id
if not text:
raise ValueError('INVALID_WHATSAPP_PAYLOAD: message/text/interactive_title is required')
session_id = payload.session_id or user_id
message_id = payload.message_id or payload.interaction_key
bc = BusinessContext(
customer_key=payload.customer_key or user_id,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key or message_id,
session_key=session_id,
metadata={'source_channel': 'whatsapp', **(payload.metadata or {})},
)
data = {
'message': text,
'session_id': session_id,
'user_id': user_id,
'message_id': message_id,
'customer_key': bc.customer_key,
'contract_key': bc.contract_key,
'interaction_key': bc.interaction_key,
'session_key': bc.session_key,
'business_context': bc.model_dump(exclude_none=True),
'metadata': {
**(payload.metadata or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'whatsapp',
'interactive_id': payload.interactive_id,
'contract_version': 'gateway-request-v1',
},
}
return GatewayRequest(channel='whatsapp', tenant_id=settings.DEFAULT_TENANT_ID, agent_id=settings.DEFAULT_AGENT_ID, payload=data)

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import httpx
from .settings import settings
from .schemas import GatewayRequest
class AgentFrameworkClient:
def __init__(self):
self.url = settings.AGENT_FRAMEWORK_BASE_URL.rstrip('/') + settings.AGENT_FRAMEWORK_GATEWAY_PATH
async def send(self, request: GatewayRequest) -> dict:
headers = {'Content-Type': 'application/json'}
if settings.INTERNAL_GATEWAY_TOKEN:
headers['X-Channel-Gateway-Token'] = settings.INTERNAL_GATEWAY_TOKEN
async with httpx.AsyncClient(timeout=settings.REQUEST_TIMEOUT_SECONDS) as client:
resp = await client.post(self.url, json=request.model_dump(exclude_none=True), headers=headers)
try:
data = resp.json()
except Exception:
data = {'text': resp.text}
if resp.status_code >= 400:
return {
'ok': False,
'status_code': resp.status_code,
'error': data,
'forwarded_to': self.url,
}
if isinstance(data, dict):
data.setdefault('ok', True)
data.setdefault('forwarded_to', self.url)
return data
return {'ok': True, 'data': data, 'forwarded_to': self.url}

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from app.adapters.web import WebAdapter
from app.adapters.whatsapp import WhatsAppAdapter
from app.adapters.voice import VoiceAdapter
from app.client import AgentFrameworkClient
from app.schemas import GatewayRequest, VoiceTranscript, WebMessage, WhatsAppWebhook
from app.settings import settings
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger('external_channel_gateway')
app = FastAPI(title='External Channel Gateway')
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(',')],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
client = AgentFrameworkClient()
web_adapter = WebAdapter()
whatsapp_adapter = WhatsAppAdapter()
voice_adapter = VoiceAdapter()
def _require_mode(expected: str, endpoint_type: str):
if settings.runtime_mode != expected:
raise HTTPException(
status_code=409,
detail={
'error_code': 'CHANNEL_GATEWAY_MODE_MISMATCH',
'message': f'{endpoint_type} endpoints require CHANNEL_GATEWAY_RUNTIME_MODE={expected}',
'current_runtime_mode': settings.runtime_mode,
},
)
async def _forward(request: GatewayRequest) -> dict:
return await client.send(request)
@app.get('/health')
async def health():
return {
'status': 'ok',
'app_name': settings.APP_NAME,
'runtime_mode': settings.runtime_mode,
'configured_runtime_mode': settings.CHANNEL_GATEWAY_RUNTIME_MODE,
'legacy_channel_gateway_mode': settings.CHANNEL_GATEWAY_MODE,
'backend_url': client.url,
'default_tenant_id': settings.DEFAULT_TENANT_ID,
'default_agent_id': settings.DEFAULT_AGENT_ID,
}
@app.post('/channels/web/message')
async def web_message(payload: WebMessage, request: Request):
_require_mode('adapter', '/channels/*')
gateway_request = await web_adapter.to_gateway_request(payload)
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/web/message',
})
return await _forward(gateway_request)
@app.post('/channels/whatsapp/webhook')
async def whatsapp_webhook(payload: WhatsAppWebhook, request: Request):
_require_mode('adapter', '/channels/*')
try:
gateway_request = await whatsapp_adapter.to_gateway_request(payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/whatsapp/webhook',
})
return await _forward(gateway_request)
@app.post('/channels/voice/transcript')
async def voice_transcript(payload: VoiceTranscript, request: Request):
_require_mode('adapter', '/channels/*')
gateway_request = await voice_adapter.to_gateway_request(payload)
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/voice/transcript',
})
return await _forward(gateway_request)
@app.post('/gateway/message')
async def proxy_gateway_message(payload: GatewayRequest, request: Request):
_require_mode('proxy', '/gateway/message')
payload.payload.setdefault('metadata', {})
payload.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/gateway/message',
'proxied_by': settings.APP_NAME,
})
return await _forward(payload)

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class BusinessContext(BaseModel):
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
protocol_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class WebMessage(BaseModel):
message: str
session_id: str | None = None
user_id: str | None = None
message_id: str | None = None
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
business_context: BusinessContext | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class WhatsAppWebhook(BaseModel):
wa_id: str | None = None
from_: str | None = Field(default=None, alias="from")
message: str | None = None
text: str | None = None
session_id: str | None = None
message_id: str | None = None
interactive_id: str | None = None
interactive_title: str | None = None
raw: dict[str, Any] = Field(default_factory=dict)
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class VoiceTranscript(BaseModel):
transcript: str
call_id: str | None = None
caller: str | None = None
session_id: str | None = None
message_id: str | None = None
confidence: float | None = None
language: str | None = None
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class GatewayRequest(BaseModel):
channel: str
payload: dict[str, Any]
agent_id: str | None = None
tenant_id: str | None = None
class GatewayResponse(BaseModel):
channel: str | None = None
session_id: str | None = None
text: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Literal
from dotenv import load_dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
load_dotenv(override=False)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
APP_NAME: str = 'external-channel-gateway'
LOG_LEVEL: str = 'INFO'
API_HOST: str = '0.0.0.0'
API_PORT: int = 7000
CORS_ORIGINS: str = 'http://localhost:5173,http://127.0.0.1:5173'
# adapter = receive native/simple channel payloads and translate them to GatewayRequest.
# proxy = receive only GatewayRequest and forward it after validation.
CHANNEL_GATEWAY_RUNTIME_MODE: Literal['adapter','proxy'] = 'adapter'
# Legacy alias accepted only for compatibility. Prefer CHANNEL_GATEWAY_RUNTIME_MODE.
CHANNEL_GATEWAY_MODE: str | None = None
AGENT_FRAMEWORK_BASE_URL: str = 'http://localhost:8000'
AGENT_FRAMEWORK_GATEWAY_PATH: str = '/gateway/message'
DEFAULT_TENANT_ID: str = 'default'
DEFAULT_AGENT_ID: str = 'telecom_contas'
REQUEST_TIMEOUT_SECONDS: float = 120.0
INTERNAL_GATEWAY_TOKEN: str | None = None
@property
def runtime_mode(self) -> str:
legacy = (self.CHANNEL_GATEWAY_MODE or '').strip().lower()
if legacy in {'adapter', 'proxy'}:
return legacy
# Legacy mapping for old deployments: embedded meant adapters on.
if legacy == 'embedded':
return 'adapter'
if legacy == 'external':
return 'proxy'
return self.CHANNEL_GATEWAY_RUNTIME_MODE
settings = Settings()

View File

@@ -0,0 +1,13 @@
channels:
web:
enabled: true
endpoint: /channels/web/message
adapter: web
whatsapp:
enabled: true
endpoint: /channels/whatsapp/webhook
adapter: whatsapp
voice:
enabled: true
endpoint: /channels/voice/transcript
adapter: voice

View File

@@ -0,0 +1,7 @@
services:
channel_gateway:
build: .
env_file:
- .env
ports:
- "7000:7000"

View File

@@ -0,0 +1,6 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
python-dotenv>=1.0.0

View File

@@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
COPY apps/mcp_gateway/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY apps/mcp_gateway /app
EXPOSE 9200
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9200"]

View File

@@ -0,0 +1,13 @@
# MCP Gateway
Camada desacoplada para descoberta, roteamento, controle e observabilidade de MCP Servers.
## Rotas
- `GET /health`
- `GET /tools`
- `POST /tools/{tool_name}/invoke`
## Configuração
O arquivo `config/mcp_servers.yaml` define os servidores e ferramentas expostas.

View File

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import httpx
import yaml
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
class ToolInvokeRequest(BaseModel):
arguments: dict[str, Any] = Field(default_factory=dict)
context: dict[str, Any] = Field(default_factory=dict)
app = FastAPI(title="Agent Framework OCI - MCP Gateway", version="0.1.0")
def load_config() -> dict[str, Any]:
config_path = Path(os.getenv("MCP_GATEWAY_CONFIG", "config/mcp_servers.yaml"))
if not config_path.exists():
return {"servers": {}}
return yaml.safe_load(config_path.read_text()) or {"servers": {}}
def find_tool(tool_name: str) -> tuple[str, dict[str, Any]] | None:
cfg = load_config()
for server_name, server in (cfg.get("servers") or {}).items():
if tool_name in (server.get("tools") or []):
return server_name, server
return None
@app.get("/health")
def health() -> dict[str, Any]:
return {"status": "ok", "component": "mcp_gateway"}
@app.get("/tools")
def tools() -> dict[str, Any]:
cfg = load_config()
result = []
for server_name, server in (cfg.get("servers") or {}).items():
for tool in server.get("tools") or []:
result.append({"name": tool, "server": server_name})
return {"tools": result}
@app.post("/tools/{tool_name}/invoke")
async def invoke_tool(tool_name: str, payload: ToolInvokeRequest) -> dict[str, Any]:
found = find_tool(tool_name)
if not found:
raise HTTPException(status_code=404, detail=f"Tool not registered in MCP Gateway: {tool_name}")
server_name, server = found
base_url = server.get("base_url")
if not base_url:
raise HTTPException(status_code=500, detail=f"Server {server_name} has no base_url")
# Conventional endpoint. Concrete MCP servers may adapt this via an adapter later.
url = f"{base_url.rstrip('/')}/tools/{tool_name}/invoke"
try:
async with httpx.AsyncClient(timeout=float(os.getenv("MCP_GATEWAY_TIMEOUT_SECONDS", "30"))) as client:
resp = await client.post(url, json=payload.model_dump())
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
data.setdefault("gateway", "mcp_gateway")
data.setdefault("server", server_name)
data.setdefault("tool", tool_name)
return data
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"MCP upstream request failed: {exc}") from exc

View File

@@ -0,0 +1,15 @@
servers:
telecom:
base_url: http://telecom-mcp-server:8101
tools:
- consultar_fatura
- consultar_pagamentos
- consultar_plano
- listar_servicos
retail:
base_url: http://retail-mcp-server:8102
tools:
- consultar_pedido
- consultar_entrega
- solicitar_troca
- solicitar_devolucao

View File

@@ -0,0 +1,5 @@
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2
httpx>=0.27
PyYAML>=6