mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-07-09 22:04:21 +00:00
First commit
This commit is contained in:
364
apps/agent_frontend/app.js
Normal file
364
apps/agent_frontend/app.js
Normal 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');
|
||||
}
|
||||
});
|
||||
49
apps/agent_frontend/index.html
Normal file
49
apps/agent_frontend/index.html
Normal 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>
|
||||
1
apps/agent_frontend/styles.css
Normal file
1
apps/agent_frontend/styles.css
Normal 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}
|
||||
35
apps/agent_gateway/.env.example
Normal file
35
apps/agent_gateway/.env.example
Normal 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
|
||||
6
apps/agent_gateway/Dockerfile
Normal file
6
apps/agent_gateway/Dockerfile
Normal 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"]
|
||||
73
apps/agent_gateway/README.md
Normal file
73
apps/agent_gateway/README.md
Normal 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
|
||||
326
apps/agent_gateway/app/main.py
Normal file
326
apps/agent_gateway/app/main.py
Normal 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",
|
||||
},
|
||||
)
|
||||
62
apps/agent_gateway/app/settings.py
Normal file
62
apps/agent_gateway/app/settings.py
Normal 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()
|
||||
38
apps/agent_gateway/config/backends.yaml
Normal file
38
apps/agent_gateway/config/backends.yaml
Normal 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
|
||||
31
apps/agent_gateway/docs/ARQUITETURA_GLOBAL_SUPERVISOR.md
Normal file
31
apps/agent_gateway/docs/ARQUITETURA_GLOBAL_SUPERVISOR.md
Normal 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.
|
||||
7
apps/agent_gateway/requirements.txt
Normal file
7
apps/agent_gateway/requirements.txt
Normal 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
|
||||
7
apps/ai_gateway/Dockerfile
Normal file
7
apps/ai_gateway/Dockerfile
Normal 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
18
apps/ai_gateway/README.md
Normal 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
|
||||
```
|
||||
0
apps/ai_gateway/app/__init__.py
Normal file
0
apps/ai_gateway/app/__init__.py
Normal file
81
apps/ai_gateway/app/main.py
Normal file
81
apps/ai_gateway/app/main.py
Normal 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
|
||||
5
apps/ai_gateway/requirements.txt
Normal file
5
apps/ai_gateway/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2
|
||||
httpx>=0.27
|
||||
PyYAML>=6
|
||||
17
apps/channel_gateway/.env.example
Normal file
17
apps/channel_gateway/.env.example
Normal 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=
|
||||
9
apps/channel_gateway/Dockerfile
Normal file
9
apps/channel_gateway/Dockerfile
Normal 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"]
|
||||
112
apps/channel_gateway/README.md
Normal file
112
apps/channel_gateway/README.md
Normal 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.
|
||||
0
apps/channel_gateway/app/__init__.py
Normal file
0
apps/channel_gateway/app/__init__.py
Normal file
0
apps/channel_gateway/app/adapters/__init__.py
Normal file
0
apps/channel_gateway/app/adapters/__init__.py
Normal file
9
apps/channel_gateway/app/adapters/base.py
Normal file
9
apps/channel_gateway/app/adapters/base.py
Normal 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: ...
|
||||
45
apps/channel_gateway/app/adapters/voice.py
Normal file
45
apps/channel_gateway/app/adapters/voice.py
Normal 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)
|
||||
35
apps/channel_gateway/app/adapters/web.py
Normal file
35
apps/channel_gateway/app/adapters/web.py
Normal 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,
|
||||
)
|
||||
42
apps/channel_gateway/app/adapters/whatsapp.py
Normal file
42
apps/channel_gateway/app/adapters/whatsapp.py
Normal 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)
|
||||
33
apps/channel_gateway/app/client.py
Normal file
33
apps/channel_gateway/app/client.py
Normal 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}
|
||||
112
apps/channel_gateway/app/main.py
Normal file
112
apps/channel_gateway/app/main.py
Normal 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)
|
||||
74
apps/channel_gateway/app/schemas.py
Normal file
74
apps/channel_gateway/app/schemas.py
Normal 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)
|
||||
46
apps/channel_gateway/app/settings.py
Normal file
46
apps/channel_gateway/app/settings.py
Normal 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()
|
||||
13
apps/channel_gateway/config/channels.yaml
Normal file
13
apps/channel_gateway/config/channels.yaml
Normal 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
|
||||
7
apps/channel_gateway/docker-compose.yml
Normal file
7
apps/channel_gateway/docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
channel_gateway:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "7000:7000"
|
||||
6
apps/channel_gateway/requirements.txt
Normal file
6
apps/channel_gateway/requirements.txt
Normal 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
|
||||
7
apps/mcp_gateway/Dockerfile
Normal file
7
apps/mcp_gateway/Dockerfile
Normal 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"]
|
||||
13
apps/mcp_gateway/README.md
Normal file
13
apps/mcp_gateway/README.md
Normal 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.
|
||||
0
apps/mcp_gateway/app/__init__.py
Normal file
0
apps/mcp_gateway/app/__init__.py
Normal file
74
apps/mcp_gateway/app/main.py
Normal file
74
apps/mcp_gateway/app/main.py
Normal 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
|
||||
15
apps/mcp_gateway/config/mcp_servers.yaml
Normal file
15
apps/mcp_gateway/config/mcp_servers.yaml
Normal 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
|
||||
5
apps/mcp_gateway/requirements.txt
Normal file
5
apps/mcp_gateway/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2
|
||||
httpx>=0.27
|
||||
PyYAML>=6
|
||||
Reference in New Issue
Block a user