feat: tool router para LiteLLM (callback + guardrail)
- CustomToolRouter como callback (roda em todas as chamadas) - ToolRouterGuardrail para ativação por request - Modos: jaccard (offline) e embedding (API) - Dockerfile + docker-compose prontos - README com docs completas - Teste offline validando ~74% redução de tokens
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
README.md
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
docker-compose.yml
|
||||||
|
*.md
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Copie para .env e preencha:
|
||||||
|
# cp .env.example .env
|
||||||
|
|
||||||
|
# Obrigatórias para o proxy falar com o provider
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
LITELLM_MASTER_KEY=sk-change-me
|
||||||
|
|
||||||
|
# Porta publicada (docker-compose)
|
||||||
|
PORT=4000
|
||||||
|
|
||||||
|
# ── Tool Router ──────────────────────────────────────────────────────────────
|
||||||
|
TOOL_ROUTER_SIMILARITY_MODE=jaccard
|
||||||
|
TOOL_ROUTER_TOP_K=3
|
||||||
|
TOOL_ROUTER_MIN_SIMILARITY=0.15
|
||||||
|
TOOL_ROUTER_ALWAYS_INCLUDE=get_time,help,list_tools
|
||||||
|
TOOL_ROUTER_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
# ── Cache Valkey (opcional — só se habilitar no config.yaml) ─────────────────
|
||||||
|
# VALKEY_HOST=valkey
|
||||||
|
# VALKEY_PORT=6379
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Env / secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs / tmp
|
||||||
|
*.log
|
||||||
|
/tmp/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.tar
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Tool Router + LiteLLM Proxy
|
||||||
|
# =============================================================================
|
||||||
|
# Build: docker build -t tool-router-litellm .
|
||||||
|
# Run: docker run --rm -p 4000:4000 --env-file .env tool-router-litellm
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PYTHONPATH=/app \
|
||||||
|
PORT=4000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependências de sistema mínimas (certs para HTTPS das APIs LLM)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# requirements.txt instala litellm + numpy + scikit-learn
|
||||||
|
# (litellm NÃO traz numpy/sklearn por padrão)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Código do router + configs
|
||||||
|
COPY tool_router_logger.py .
|
||||||
|
COPY config.yaml .
|
||||||
|
COPY test_tool_router.py .
|
||||||
|
|
||||||
|
# Porta do proxy
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
# Healthcheck simples no endpoint do proxy
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fsS "http://127.0.0.1:${PORT}/health" || exit 1
|
||||||
|
|
||||||
|
# Variáveis TOOL_ROUTER_* e OPENAI_API_KEY / LITELLM_MASTER_KEY vêm do runtime
|
||||||
|
CMD ["sh", "-c", "litellm --config /app/config.yaml --port ${PORT} --host 0.0.0.0"]
|
||||||
292
README.md
Normal file
292
README.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
# Tool Router para LiteLLM
|
||||||
|
|
||||||
|
Custom callback / guardrail para o **LiteLLM Proxy** que filtra tools irrelevantes **antes** da chamada ao LLM, reduzindo tokens de input.
|
||||||
|
|
||||||
|
Em testes locais com 10 tools: **~74% de redução** de tokens só no payload de tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sumário
|
||||||
|
|
||||||
|
1. [O problema](#o-problema)
|
||||||
|
2. [Como funciona](#como-funciona)
|
||||||
|
3. [Arquivos do projeto](#arquivos-do-projeto)
|
||||||
|
4. [Jaccard vs Embedding](#jaccard-vs-embedding)
|
||||||
|
5. [Instalação](#instalação)
|
||||||
|
6. [Configuração no proxy](#configuração-no-proxy)
|
||||||
|
7. [Parâmetros e env vars](#parâmetros-e-env-vars)
|
||||||
|
8. [Como testar](#como-testar)
|
||||||
|
9. [Docker](#docker)
|
||||||
|
10. [FAQ](#faq)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O problema
|
||||||
|
|
||||||
|
Agentes com muitas tools enviam **todas** as definições (JSON schemas) em **toda** request. Um simples `"Oi"` pode gastar milhares de tokens só com schemas irrelevantes.
|
||||||
|
|
||||||
|
## Como funciona
|
||||||
|
|
||||||
|
```
|
||||||
|
Usuário: "Qual o clima em SP?" + 10 tools
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────┐
|
||||||
|
│ async_pre_call_hook() │
|
||||||
|
│ 1. Extrai a query do usuário │
|
||||||
|
│ 2. Similaridade com cada tool │
|
||||||
|
│ 3. Top-K + always_include │
|
||||||
|
└──────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Só 2–3 tools relevantes → LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
Dois modos de uso:
|
||||||
|
|
||||||
|
| Modo | Quando roda | Como ativar |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| **Callback** | Todas as chamadas | `callbacks: [tool_router_logger.tool_router_instance]` |
|
||||||
|
| **Guardrail** | Por request (ou `default_on: true`) | `guardrails: [...]` + body `"guardrails": ["tool_router"]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquivos do projeto
|
||||||
|
|
||||||
|
```
|
||||||
|
tool-router-litellm/
|
||||||
|
├── tool_router_logger.py # CustomToolRouter + ToolRouterGuardrail
|
||||||
|
├── config.yaml # Exemplo de config do proxy
|
||||||
|
├── test_tool_router.py # Teste offline (jaccard)
|
||||||
|
├── requirements.txt # Dependências
|
||||||
|
├── Dockerfile # Imagem do proxy + router
|
||||||
|
├── docker-compose.yml # Subir com 1 comando
|
||||||
|
├── .env.example # Variáveis de ambiente
|
||||||
|
├── .gitignore
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jaccard vs Embedding
|
||||||
|
|
||||||
|
| | **Jaccard** (default) | **Embedding** |
|
||||||
|
|---|---|---|
|
||||||
|
| Dependência de API | Nenhuma | Precisa de modelo de embedding |
|
||||||
|
| Custo extra | Zero | Tokens de embedding |
|
||||||
|
| Latência | ~ms | Depende da API |
|
||||||
|
| Precisão | Boa (sinônimos PT/EN) | Melhor semanticamente |
|
||||||
|
| Offline | Sim | Não |
|
||||||
|
|
||||||
|
Recomendação: comece com **jaccard**. Mude para **embedding** se o filtro errar em queries muito abstratas.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jaccard (default)
|
||||||
|
export TOOL_ROUTER_SIMILARITY_MODE=jaccard
|
||||||
|
|
||||||
|
# Embedding
|
||||||
|
export TOOL_ROUTER_SIMILARITY_MODE=embedding
|
||||||
|
export TOOL_ROUTER_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalação
|
||||||
|
|
||||||
|
### O LiteLLM já traz numpy/scikit-learn?
|
||||||
|
|
||||||
|
**Não.** O `litellm` **não** inclui `numpy` nem `scikit-learn` por padrão.
|
||||||
|
Esses pacotes são **obrigatórios** para o Tool Router (jaccard e cosine similarity).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone / entre no repo
|
||||||
|
cd tool-router-litellm
|
||||||
|
|
||||||
|
# 2. Instale as deps (litellm + numpy + scikit-learn)
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 3. Configure env
|
||||||
|
cp .env.example .env
|
||||||
|
# edite OPENAI_API_KEY, LITELLM_MASTER_KEY, etc.
|
||||||
|
|
||||||
|
# 4. Suba o proxy
|
||||||
|
litellm --config config.yaml --port 4000
|
||||||
|
```
|
||||||
|
|
||||||
|
> O `PYTHONPATH` precisa incluir o diretório deste repo para o proxy importar `tool_router_logger`.
|
||||||
|
> No Docker isso já é resolvido. Localmente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONPATH="$(pwd):$PYTHONPATH"
|
||||||
|
litellm --config config.yaml --port 4000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuração no proxy
|
||||||
|
|
||||||
|
### Opção A — Callback (recomendado: roda sempre)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
litellm_settings:
|
||||||
|
callbacks:
|
||||||
|
- tool_router_logger.tool_router_instance
|
||||||
|
```
|
||||||
|
|
||||||
|
Parâmetros vêm de **env vars** `TOOL_ROUTER_*` (ver abaixo).
|
||||||
|
|
||||||
|
### Opção B — Guardrail (controle por request)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
litellm_settings:
|
||||||
|
guardrails:
|
||||||
|
- guardrail_name: tool_router
|
||||||
|
litellm_params:
|
||||||
|
guardrail: tool_router_logger.ToolRouterGuardrail
|
||||||
|
mode: pre_call
|
||||||
|
default_on: false # true = roda em todas as calls
|
||||||
|
top_k: 3
|
||||||
|
min_similarity: 0.15
|
||||||
|
similarity_mode: jaccard # ou embedding
|
||||||
|
always_include_tools:
|
||||||
|
- get_time
|
||||||
|
- help
|
||||||
|
- list_tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Ativar no request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:4000/chat/completions \
|
||||||
|
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"messages": [{"role": "user", "content": "Qual o clima em SP?"}],
|
||||||
|
"tools": [ ... ],
|
||||||
|
"guardrails": ["tool_router"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Callback vs Guardrail
|
||||||
|
|
||||||
|
| | Callback | Guardrail |
|
||||||
|
|---|---|---|
|
||||||
|
| Roda sempre | Sim | Só se `default_on: true` ou pedido no body |
|
||||||
|
| Controle por request | Não | Sim (`"guardrails": ["tool_router"]`) |
|
||||||
|
| Setup | Mais simples | Mais flexível |
|
||||||
|
| Ideal para | Reduzir tokens em tudo | Filtrar só em alguns fluxos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parâmetros e env vars
|
||||||
|
|
||||||
|
| Env var | Default | Descrição |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| `TOOL_ROUTER_SIMILARITY_MODE` | `jaccard` | `jaccard` ou `embedding` |
|
||||||
|
| `TOOL_ROUTER_TOP_K` | `3` | Quantas tools (além de always_include) |
|
||||||
|
| `TOOL_ROUTER_MIN_SIMILARITY` | `0.15` | Threshold mínimo |
|
||||||
|
| `TOOL_ROUTER_ALWAYS_INCLUDE` | `get_time,help,list_tools` | Tools sempre enviadas (CSV) |
|
||||||
|
| `TOOL_ROUTER_EMBEDDING_MODEL` | `text-embedding-3-small` | Modelo no modo embedding |
|
||||||
|
|
||||||
|
No modo **guardrail**, os mesmos campos podem ir em `litellm_params` do `config.yaml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Como testar
|
||||||
|
|
||||||
|
### 1. Teste offline (sem API key, sem proxy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test_tool_router.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Saída esperada: redução de ~70–75% nos chars das tools.
|
||||||
|
|
||||||
|
### 2. Proxy local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONPATH="$(pwd):$PYTHONPATH"
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
export LITELLM_MASTER_KEY=sk-test-master
|
||||||
|
export TOOL_ROUTER_SIMILARITY_MODE=jaccard
|
||||||
|
|
||||||
|
litellm --config config.yaml --port 4000 --detailed_debug
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:4000/chat/completions \
|
||||||
|
-H "Authorization: Bearer sk-test-master" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"messages": [{"role": "user", "content": "Qual a previsão do tempo em São Paulo?"}],
|
||||||
|
"tools": [
|
||||||
|
{"type":"function","function":{"name":"get_time","description":"Obtém a hora atual","parameters":{"type":"object","properties":{}}}},
|
||||||
|
{"type":"function","function":{"name":"get_weather","description":"Obtém a previsão do tempo","parameters":{"type":"object","properties":{}}}},
|
||||||
|
{"type":"function","function":{"name":"send_email","description":"Envia um email","parameters":{"type":"object","properties":{}}}},
|
||||||
|
{"type":"function","function":{"name":"execute_bash","description":"Executa comando bash","parameters":{"type":"object","properties":{}}}},
|
||||||
|
{"type":"function","function":{"name":"search_web","description":"Pesquisa na web","parameters":{"type":"object","properties":{}}}}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
No log com `--detailed_debug`, o `completion()` deve receber só as tools filtradas (ex.: `get_time` + `get_weather`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Build e run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edite OPENAI_API_KEY e LITELLM_MASTER_KEY
|
||||||
|
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy em `http://localhost:4000`.
|
||||||
|
|
||||||
|
### Só Dockerfile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t tool-router-litellm .
|
||||||
|
docker run --rm -p 4000:4000 \
|
||||||
|
--env-file .env \
|
||||||
|
-e TOOL_ROUTER_SIMILARITY_MODE=jaccard \
|
||||||
|
tool-router-litellm
|
||||||
|
```
|
||||||
|
|
||||||
|
A imagem:
|
||||||
|
|
||||||
|
1. Instala `requirements.txt` (**litellm + numpy + scikit-learn**)
|
||||||
|
2. Copia o código para `/app`
|
||||||
|
3. Sobe `litellm --config config.yaml --port 4000`
|
||||||
|
4. Já coloca `/app` no `PYTHONPATH` para importar `tool_router_logger`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Preciso instalar o `requirements.txt` manualmente?**
|
||||||
|
Sim, se for local. O LiteLLM sozinho **não** traz `numpy`/`scikit-learn`. No Docker o `Dockerfile` já instala tudo.
|
||||||
|
|
||||||
|
**Posso usar só como guardrail?**
|
||||||
|
Sim. Comente `callbacks` e descomente o bloco `guardrails` no `config.yaml`.
|
||||||
|
|
||||||
|
**O cache Valkey é obrigatório?**
|
||||||
|
Não. No `config.yaml` de exemplo ele está configurado; se não tiver Valkey, remova `cache` / `cache_params` ou use o `config` embutido no Docker (sem Valkey por default no compose simples).
|
||||||
|
|
||||||
|
**Funciona com function calling / tools do OpenAI?**
|
||||||
|
Sim. O hook altera `data["tools"]` no pre-call; o resto do fluxo do proxy permanece igual.
|
||||||
|
|
||||||
|
**E se o embedding falhar?**
|
||||||
|
O router loga warning e faz fallback (não derruba a request). Em produção, prefira jaccard se a API de embedding for instável.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licença
|
||||||
|
|
||||||
|
Use como quiser no seu proxy / agente. Sem warranty.
|
||||||
76
config.yaml
Normal file
76
config.yaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# config.yaml - LiteLLM Proxy com Tool Router + Valkey Semantic Cache
|
||||||
|
# ============================================================================
|
||||||
|
# Para rodar:
|
||||||
|
# litellm --config config.yaml --detailed_debug
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
model_list:
|
||||||
|
- model_name: gpt-4o-mini
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gpt-4o-mini
|
||||||
|
api_key: os.environ/OPENAI_API_KEY
|
||||||
|
# Temperatura baixa para ferramentas
|
||||||
|
temperature: 0.1
|
||||||
|
- model_name: gpt-4o
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gpt-4o
|
||||||
|
api_key: os.environ/OPENAI_API_KEY
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
# Cache semântico com Valkey (reduz tokens em resposta)
|
||||||
|
cache: true
|
||||||
|
cache_params:
|
||||||
|
type: valkey-semantic
|
||||||
|
host: os.environ/VALKEY_HOST
|
||||||
|
port: os.environ/VALKEY_PORT
|
||||||
|
valkey_semantic_cache_embedding_model: "text-embedding-3-small"
|
||||||
|
similarity_threshold: 0.92 # só retorna cache se >= 92% similar
|
||||||
|
ttl: 3600 # 1 hora
|
||||||
|
|
||||||
|
# ── Tool Router (roda em TODAS as chamadas) ────────────────────────────────
|
||||||
|
# Formato válido no LiteLLM 1.95+: `callbacks` aponta para uma INSTÂNCIA
|
||||||
|
# de CustomLogger (`tool_router_instance` é criada no import do módulo).
|
||||||
|
# Parâmetros configuráveis via env vars: TOOL_ROUTER_* (ver tool_router_logger.py).
|
||||||
|
callbacks:
|
||||||
|
- tool_router_logger.tool_router_instance
|
||||||
|
|
||||||
|
# ── Opção GUARDRAIL (ativa por request: `guardrails: ["tool_router"]`) ─────
|
||||||
|
# Descomente se preferir controlar por request em vez de global.
|
||||||
|
# guardrails:
|
||||||
|
# - guardrail_name: tool_router
|
||||||
|
# litellm_params:
|
||||||
|
# guardrail: tool_router_logger.ToolRouterGuardrail
|
||||||
|
# mode: pre_call
|
||||||
|
# default_on: false # true = roda em todas as chamadas
|
||||||
|
# top_k: 3
|
||||||
|
# min_similarity: 0.15
|
||||||
|
# similarity_mode: jaccard # ou "embedding" (exige API de embedding)
|
||||||
|
|
||||||
|
# Callbacks para monitoramento
|
||||||
|
success_callback: ["langfuse"] # opcional: rastreamento
|
||||||
|
|
||||||
|
# Desliga cache de requisições (deixa só o semântico) se quiser
|
||||||
|
# request_model_request_count: false
|
||||||
|
|
||||||
|
general_settings:
|
||||||
|
master_key: os.environ/LITELLM_MASTER_KEY
|
||||||
|
# database_url: os.environ/DATABASE_URL # se quiser persitir usage
|
||||||
|
|
||||||
|
# Proxy custom headers
|
||||||
|
proxy_batch_write_at: 1 # escreve logs em batch
|
||||||
|
|
||||||
|
# Segurança
|
||||||
|
allowed_routes:
|
||||||
|
- chat/completions
|
||||||
|
- models
|
||||||
|
|
||||||
|
# Rate limiting (opcional)
|
||||||
|
# alerting: ["slack"]
|
||||||
|
# alerting_threshold: 300 # 5 minutos sem resposta
|
||||||
|
|
||||||
|
# Desabilitar rotas indesejadas
|
||||||
|
# disable_end_user_cost_tracking: true
|
||||||
|
|
||||||
|
# Para ambientes com muitos tools, configurar:
|
||||||
|
max_request_size_mb: 15
|
||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
litellm:
|
||||||
|
build: .
|
||||||
|
image: tool-router-litellm:latest
|
||||||
|
container_name: tool-router-litellm
|
||||||
|
ports:
|
||||||
|
- "${PORT:-4000}:4000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# Garante import do tool_router_logger
|
||||||
|
PYTHONPATH: /app
|
||||||
|
# Defaults do router (sobrescreva no .env se quiser)
|
||||||
|
TOOL_ROUTER_SIMILARITY_MODE: ${TOOL_ROUTER_SIMILARITY_MODE:-jaccard}
|
||||||
|
TOOL_ROUTER_TOP_K: ${TOOL_ROUTER_TOP_K:-3}
|
||||||
|
TOOL_ROUTER_MIN_SIMILARITY: ${TOOL_ROUTER_MIN_SIMILARITY:-0.15}
|
||||||
|
TOOL_ROUTER_ALWAYS_INCLUDE: ${TOOL_ROUTER_ALWAYS_INCLUDE:-get_time,help,list_tools}
|
||||||
|
TOOL_ROUTER_EMBEDDING_MODEL: ${TOOL_ROUTER_EMBEDDING_MODEL:-text-embedding-3-small}
|
||||||
|
volumes:
|
||||||
|
# Permite editar config sem rebuild
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
- ./tool_router_logger.py:/app/tool_router_logger.py:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:4000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
# Opcional: descomente se for usar cache semântico Valkey no config.yaml
|
||||||
|
# valkey:
|
||||||
|
# image: valkey/valkey:8-alpine
|
||||||
|
# container_name: tool-router-valkey
|
||||||
|
# ports:
|
||||||
|
# - "6379:6379"
|
||||||
|
# restart: unless-stopped
|
||||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Dependências do Tool Router + LiteLLM Proxy
|
||||||
|
# Instalação: pip install -r requirements.txt
|
||||||
|
#
|
||||||
|
# NOTA: litellm NÃO inclui numpy/scikit-learn por padrão.
|
||||||
|
# Esses pacotes são necessários para o modo jaccard (cosine_similarity)
|
||||||
|
# e para o modo embedding (vetores).
|
||||||
|
|
||||||
|
litellm>=1.95.0
|
||||||
|
numpy>=1.26.0
|
||||||
|
scikit-learn>=1.3.0
|
||||||
|
|
||||||
|
# Opcional: cache semântico com Valkey/Redis
|
||||||
|
# redis>=5.0.0
|
||||||
251
test_tool_router.py
Normal file
251
test_tool_router.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""
|
||||||
|
Teste rápido do ToolRouter - simula chamadas pra provar a redução de tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Adiciona o diretório atual ao path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from tool_router_logger import CustomToolRouter
|
||||||
|
|
||||||
|
|
||||||
|
# Simula tools como se fossem do hermes-agent
|
||||||
|
TOOLS_MOCK = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_time",
|
||||||
|
"description": "Obtém a hora atual em um fuso horário específico",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fuso horário (ex: America/Sao_Paulo)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_web",
|
||||||
|
"description": "Pesquisa na web usando um mecanismo de busca. Retorna resultados de páginas web relevantes para a consulta.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Consulta de busca"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Lê o conteúdo de um arquivo no sistema de arquivos. Pode ler arquivos de texto e retornar seu conteúdo.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Caminho do arquivo"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "write_file",
|
||||||
|
"description": "Escreve conteúdo em um arquivo no sistema de arquivos.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Caminho do arquivo"},
|
||||||
|
"content": {"type": "string", "description": "Conteúdo"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_directory",
|
||||||
|
"description": "Lista arquivos e diretórios em um determinado caminho.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Caminho"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "execute_bash",
|
||||||
|
"description": "Executa um comando bash no terminal.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string", "description": "Comando bash"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Obtém a previsão do tempo para uma cidade.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"city": {"type": "string", "description": "Nome da cidade"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "send_email",
|
||||||
|
"description": "Envia um email para um destinatário.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"to": {"type": "string", "description": "Destinatário"},
|
||||||
|
"subject": {"type": "string", "description": "Assunto"},
|
||||||
|
"body": {"type": "string", "description": "Corpo do email"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "calculate",
|
||||||
|
"description": "Realiza cálculos matemáticos complexos.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expression": {"type": "string", "description": "Expressão"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_tools",
|
||||||
|
"description": "Lista todas as ferramentas disponíveis com suas descrições.",
|
||||||
|
"parameters": {"type": "object", "properties": {}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_router():
|
||||||
|
"""Testa o router com diferentes mensagens."""
|
||||||
|
|
||||||
|
router = CustomToolRouter(
|
||||||
|
embedding_model="text-embedding-3-small", # modelo real via API
|
||||||
|
top_k=3,
|
||||||
|
min_similarity=0.15,
|
||||||
|
always_include_tools={"get_time", "help", "list_tools"},
|
||||||
|
similarity_mode="jaccard", # fallback local (sem precisar de API key)
|
||||||
|
)
|
||||||
|
|
||||||
|
test_cases = [
|
||||||
|
("👋 Oi, tudo bem?", "saudação simples - deve selecionar ~0 tools"),
|
||||||
|
("Que horas são em São Paulo?", "pergunta sobre hora"),
|
||||||
|
("Pesquise sobre IA generativa na web", "pesquisa web"),
|
||||||
|
("Leia o arquivo /tmp/test.txt pra mim", "leitura de arquivo"),
|
||||||
|
("Qual a previsão do tempo em SP?", "clima/weather"),
|
||||||
|
("Execute ls -la no terminal", "comando bash"),
|
||||||
|
("Me mande um email para teste@test.com", "envio de email"),
|
||||||
|
("Quanto é 2 + 2?", "cálculo"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("🧪 TESTE DO TOOL ROUTER PARA REDUÇÃO DE TOKENS")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"\nTools disponíveis: {len(TOOLS_MOCK)}")
|
||||||
|
print(f"Always include: {router.always_include_tools}")
|
||||||
|
print(f"Modo: {router.similarity_mode}")
|
||||||
|
print(f"Top-K: {router.top_k}, Min similarity: {router.min_similarity}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
total_original = 0
|
||||||
|
total_filtrado = 0
|
||||||
|
|
||||||
|
for msg, desc in test_cases:
|
||||||
|
print(f"\n{'─' * 70}")
|
||||||
|
print(f"📝 Teste: '{msg}'")
|
||||||
|
print(f"📌 {desc}")
|
||||||
|
|
||||||
|
# Simula o data dict que o proxy passa
|
||||||
|
data = {
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Você é um assistente útil."},
|
||||||
|
{"role": "user", "content": msg},
|
||||||
|
],
|
||||||
|
"tools": TOOLS_MOCK,
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens_original = len(json.dumps(data["tools"]))
|
||||||
|
|
||||||
|
# Executa o hook
|
||||||
|
result = await router.async_pre_call_hook(
|
||||||
|
user_api_key_dict=None, # mock
|
||||||
|
cache=None, # mock
|
||||||
|
data=data,
|
||||||
|
call_type="completion",
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
new_tools = result.get("tools", [])
|
||||||
|
else:
|
||||||
|
new_tools = TOOLS_MOCK
|
||||||
|
|
||||||
|
tokens_filtrado = len(json.dumps(new_tools))
|
||||||
|
reducao = (1 - tokens_filtrado / tokens_original) * 100 if tokens_original > 0 else 0
|
||||||
|
|
||||||
|
selected = [t.get("function", t).get("name", "") for t in new_tools]
|
||||||
|
|
||||||
|
total_original += tokens_original
|
||||||
|
total_filtrado += tokens_filtrado
|
||||||
|
|
||||||
|
print(f" Tools: {len(TOOLS_MOCK)} → {len(new_tools)}")
|
||||||
|
print(f" Selecionadas: {selected}")
|
||||||
|
print(f" Tokens (chars): {tokens_original} → {tokens_filtrado} (🔥 -{reducao:.1f}%)")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 70}")
|
||||||
|
reducao_total = (1 - total_filtrado / total_original) * 100
|
||||||
|
print(f"📊 TOTAL GERAL: {total_original} → {total_filtrado} chars (🔥 -{reducao_total:.1f}%)")
|
||||||
|
print(f" Economia média por chamada: ~{total_original - total_filtrado} chars")
|
||||||
|
print(f"{'=' * 70}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# --- Cenário real: estimativa de tokens LLM ---
|
||||||
|
print("\n📈 ESTIMATIVA DE TOKENS REAIS (considerando modelo)")
|
||||||
|
print("~4 chars = 1 token em média para tools JSON")
|
||||||
|
|
||||||
|
tok_original = total_original // 4
|
||||||
|
tok_filtrado = total_filtrado // 4
|
||||||
|
economia_tokens = tok_original - tok_filtrado
|
||||||
|
|
||||||
|
print(f" Tokens originais (tools): ~{tok_original}")
|
||||||
|
print(f" Tokens filtrados (tools): ~{tok_filtrado}")
|
||||||
|
print(f" Economia por chamada: ~{economia_tokens} tokens")
|
||||||
|
print(f" Preço @ GPT-4o-mini: ${economia_tokens * 0.000000150:.6f}/chamada (só tools)")
|
||||||
|
print(f" Preço @ GPT-4o: ${economia_tokens * 0.0000025:.6f}/chamada (só tools)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_tool_router())
|
||||||
603
tool_router_logger.py
Normal file
603
tool_router_logger.py
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
"""
|
||||||
|
Tool Router - LiteLLM Custom Logger / Guardrail
|
||||||
|
===============================================
|
||||||
|
Intercepta as chamadas do proxy do LiteLLM e faz retrieval das tools,
|
||||||
|
reduzindo drasticamente o consumo de tokens.
|
||||||
|
|
||||||
|
Como funciona:
|
||||||
|
1. Indexa cada tool (name + description)
|
||||||
|
2. No pre-call hook, calcula similaridade com a query do usuário
|
||||||
|
3. Seleciona top-K tools (+ always_include)
|
||||||
|
4. Só envia essas tools para o LLM
|
||||||
|
|
||||||
|
Uso como CALLBACK (roda em todas as chamadas):
|
||||||
|
litellm_settings:
|
||||||
|
callbacks:
|
||||||
|
- tool_router_logger.tool_router_instance
|
||||||
|
|
||||||
|
Uso como GUARDRAIL (ativa por request):
|
||||||
|
litellm_settings:
|
||||||
|
guardrails:
|
||||||
|
- guardrail_name: tool_router
|
||||||
|
litellm_params:
|
||||||
|
guardrail: tool_router_logger.ToolRouterGuardrail
|
||||||
|
mode: pre_call
|
||||||
|
default_on: false
|
||||||
|
|
||||||
|
Ou programaticamente:
|
||||||
|
litellm.callbacks = [CustomToolRouter(...)]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
|
|
||||||
|
from litellm.integrations.custom_logger import CustomLogger
|
||||||
|
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||||
|
from litellm.proxy._types import UserAPIKeyAuth
|
||||||
|
from litellm.caching import DualCache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Embedding local leve (sem dependência externa pesada)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FastEmbedder:
|
||||||
|
"""
|
||||||
|
Usa o modelo de embedding do LiteLLM (qualquer provider via API)
|
||||||
|
pra evitar depender de sentence-transformers local.
|
||||||
|
|
||||||
|
Alternativa: Cache as embeddings no Valkey para reuse entre chamadas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, embedding_model: str = "text-embedding-ada-002", cache: Optional[DualCache] = None):
|
||||||
|
self.embedding_model = embedding_model
|
||||||
|
self.cache = cache
|
||||||
|
self._local_cache: Dict[str, List[float]] = {}
|
||||||
|
|
||||||
|
async def embed(self, texts: List[str], litellm_kwargs: Optional[Dict] = None) -> List[List[float]]:
|
||||||
|
"""Gera embeddings em lote usando o LiteLLM."""
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
to_embed = []
|
||||||
|
indices = []
|
||||||
|
results = [None] * len(texts)
|
||||||
|
|
||||||
|
# Verifica cache primeiro (se disponível)
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
text_hash = hashlib.md5(text.encode()).hexdigest()
|
||||||
|
cache_key = f"tool_embed:{text_hash}"
|
||||||
|
|
||||||
|
if cache_key in self._local_cache:
|
||||||
|
results[i] = self._local_cache[cache_key]
|
||||||
|
elif self.cache:
|
||||||
|
cached = await self.cache.async_get_cache(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
val = cached
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = json.loads(val)
|
||||||
|
self._local_cache[cache_key] = val
|
||||||
|
results[i] = val
|
||||||
|
else:
|
||||||
|
to_embed.append(text)
|
||||||
|
indices.append(i)
|
||||||
|
else:
|
||||||
|
to_embed.append(text)
|
||||||
|
indices.append(i)
|
||||||
|
|
||||||
|
if not to_embed:
|
||||||
|
return results # type: ignore
|
||||||
|
|
||||||
|
# Faz chamada de embedding
|
||||||
|
kwargs = {"model": self.embedding_model, "input": to_embed}
|
||||||
|
if litellm_kwargs:
|
||||||
|
kwargs.update(litellm_kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await litellm.aembedding(**kwargs)
|
||||||
|
for idx, emb_data in zip(indices, response.data):
|
||||||
|
embedding = emb_data["embedding"] if isinstance(emb_data, dict) else emb_data.embedding
|
||||||
|
results[idx] = embedding
|
||||||
|
|
||||||
|
# Salva no cache
|
||||||
|
text = to_embed[indices.index(idx)]
|
||||||
|
text_hash = hashlib.md5(text.encode()).hexdigest()
|
||||||
|
cache_key = f"tool_embed:{text_hash}"
|
||||||
|
self._local_cache[cache_key] = embedding
|
||||||
|
if self.cache:
|
||||||
|
await self.cache.async_set_cache(cache_key, embedding)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Embedding failed, falling back to empty: {e}")
|
||||||
|
for idx in indices:
|
||||||
|
results[idx] = [0.0] * 1536 # fallback
|
||||||
|
|
||||||
|
return results # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Similaridade textual inteligente (fallback sem embedding externo)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import string
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
# Mapa de sinônimos e lematização para português + inglês (tools geralmente
|
||||||
|
# têm descrições em inglês, queries em português)
|
||||||
|
_SYNONYM_MAP = {
|
||||||
|
# verbos para substantivos (português)
|
||||||
|
"pesquise": "pesquisa", "pesquisar": "pesquisa", "buscar": "pesquisa", "busca": "pesquisa",
|
||||||
|
"procure": "pesquisa", "procurar": "pesquisa", "encontre": "pesquisa",
|
||||||
|
"leia": "leitura", "ler": "leitura", "abrir": "leitura", "abra": "leitura",
|
||||||
|
"escreva": "escrita", "escrever": "escrita", "salvar": "escrita",
|
||||||
|
"execute": "execução", "executar": "execução", "rodar": "execução", "rode": "execução",
|
||||||
|
"liste": "listagem", "listar": "listagem", "mostre": "listagem",
|
||||||
|
"envie": "envio", "enviar": "envio", "mande": "envio",
|
||||||
|
"calcule": "cálculo", "calcular": "cálculo", "calcular": "cálculo",
|
||||||
|
# português → inglês (tools descriptions são em inglês)
|
||||||
|
"hora": "time", "horas": "time", "horário": "time", "horario": "time",
|
||||||
|
"relógio": "time", "relogio": "time",
|
||||||
|
"tempo": "weather time", # ambos
|
||||||
|
"clima": "weather", "previsão": "weather", "previsao": "weather",
|
||||||
|
"chuva": "weather", "temperatura": "weather", "temperatures": "weather",
|
||||||
|
"cidade": "city",
|
||||||
|
"arquivo": "file", "arquivos": "file", "documento": "file",
|
||||||
|
"pasta": "directory", "diretório": "directory", "diretorio": "directory",
|
||||||
|
"email": "email", "e-mail": "email", "mensagem": "email",
|
||||||
|
"web": "web", "internet": "web", "site": "web", "pesquisar": "search",
|
||||||
|
"bash": "bash", "terminal": "bash", "comando": "bash command", "shell": "bash",
|
||||||
|
"linha": "line",
|
||||||
|
"navegador": "browser",
|
||||||
|
"sistema": "system", "arquivos": "files",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Stopwords comuns em português e inglês (palavras muito curtas/frequentes)
|
||||||
|
_STOPWORDS = {
|
||||||
|
"a", "e", "o", "em", "de", "da", "do", "na", "no", "um", "uma",
|
||||||
|
"é", "ser", "tem", "com", "para", "por", "pra", "os", "as",
|
||||||
|
"ao", "aos", "das", "dos", "nas", "nos", "que", "se", "mas",
|
||||||
|
"mais", "muito", "como", "vai", "vou", "me", "te", "se",
|
||||||
|
"the", "a", "an", "in", "on", "at", "to", "for", "of",
|
||||||
|
"is", "it", "be", "with", "and", "or", "from", "by",
|
||||||
|
"um", "uns", "uma", "umas",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(text: str) -> list:
|
||||||
|
"""Tokeniza e normaliza, retornando lista (não set) pra preservar frequência."""
|
||||||
|
text = text.lower().translate(str.maketrans('', '', string.punctuation))
|
||||||
|
tokens = re.findall(r'[a-záéíóúãõâêîôûç]+', text)
|
||||||
|
# Remove stopwords
|
||||||
|
tokens = [t for t in tokens if t not in _STOPWORDS and len(t) > 1]
|
||||||
|
# Aplica sinônimos
|
||||||
|
expanded = []
|
||||||
|
for t in tokens:
|
||||||
|
expanded.append(t)
|
||||||
|
if t in _SYNONYM_MAP:
|
||||||
|
expanded.extend(_SYNONYM_MAP[t].split())
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def _jaccard_similarity(a: set, b: set) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
return len(a & b) / len(a | b)
|
||||||
|
|
||||||
|
|
||||||
|
def _tf_keyword_score(query_tokens: list, tool_text: str) -> float:
|
||||||
|
"""
|
||||||
|
Score baseado em quantas palavras-chave da query aparecem no texto da tool.
|
||||||
|
Penaliza tools que não contêm NENHUMA palavra relevante.
|
||||||
|
"""
|
||||||
|
tool_lower = tool_text.lower()
|
||||||
|
matches = sum(1 for q in query_tokens if q in tool_lower)
|
||||||
|
if matches == 0:
|
||||||
|
return 0.0
|
||||||
|
# Normaliza pelo tamanho da query pra favorecer matches parciais
|
||||||
|
return matches / max(len(set(query_tokens)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _combined_text_similarity(query: str, tool_text: str) -> float:
|
||||||
|
"""
|
||||||
|
Combina Jaccard (set overlap) + TF-keyword score pra dar
|
||||||
|
similaridade mais robusta sem precisar de embeddings externos.
|
||||||
|
"""
|
||||||
|
query_tokens = _tokenize(query)
|
||||||
|
tool_tokens = _tokenize(tool_text)
|
||||||
|
|
||||||
|
query_set = set(query_tokens)
|
||||||
|
tool_set = set(tool_tokens)
|
||||||
|
|
||||||
|
# Jaccard entre sets
|
||||||
|
jac = _jaccard_similarity(query_set, tool_set)
|
||||||
|
|
||||||
|
# TF-score: quantas palavras da query estão no texto da tool
|
||||||
|
tf = _tf_keyword_score(query_tokens, tool_text)
|
||||||
|
|
||||||
|
# Identifica match exato no nome da tool (peso máximo)
|
||||||
|
query_words = set(w for w in query_tokens if len(w) > 2)
|
||||||
|
tool_name = tool_text.split()[0] if tool_text else ""
|
||||||
|
name_match = 1.0 if tool_name in query_words else 0.0
|
||||||
|
|
||||||
|
# Se o nome da tool apareceu na query, é quase certeza
|
||||||
|
if name_match > 0:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Peso maior pro TF quando Jaccard é baixo (captura relações indiretas)
|
||||||
|
if jac == 0 and tf > 0:
|
||||||
|
return tf * 0.4 # meio caminho, mais conservador
|
||||||
|
|
||||||
|
# Só retorna algo relevante se passou de um mínimo
|
||||||
|
raw = max(jac, tf * 0.6)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CustomLogger principal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_TOOL_NAMES = {"get_time", "help", "ping", "status"}
|
||||||
|
|
||||||
|
class CustomToolRouter(CustomLogger):
|
||||||
|
"""
|
||||||
|
Router semântico de tools para LiteLLM Proxy.
|
||||||
|
|
||||||
|
No pre-call hook, faz retrieval das tools mais relevantes
|
||||||
|
para a query do usuário, reduzindo o prompt de tools enviado ao LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
embedding_model: Modelo de embedding (ex: 'text-embedding-ada-002')
|
||||||
|
top_k: Número de tools a incluir (default: 3)
|
||||||
|
min_similarity: Similaridade mínima para incluir tool (default: 0.15)
|
||||||
|
always_include_tools: Lista de nomes de tools sempre enviadas
|
||||||
|
similarity_mode: "embedding" (preferido) ou "jaccard" (fallback leve)
|
||||||
|
cache_host: Host do Valkey/Redis (opcional)
|
||||||
|
cache_port: Porta do Valkey/Redis (opcional)
|
||||||
|
cache_password: Senha (opcional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
embedding_model: str = "text-embedding-ada-002",
|
||||||
|
top_k: int = 3,
|
||||||
|
min_similarity: float = 0.15,
|
||||||
|
always_include_tools: Optional[list] = None,
|
||||||
|
similarity_mode: str = "embedding",
|
||||||
|
cache_host: Optional[str] = None,
|
||||||
|
cache_port: Optional[str] = None,
|
||||||
|
cache_password: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.top_k = top_k
|
||||||
|
self.min_similarity = min_similarity
|
||||||
|
self.always_include_tools = set(always_include_tools or DEFAULT_TOOL_NAMES)
|
||||||
|
self.similarity_mode = similarity_mode
|
||||||
|
self.embedding_model = embedding_model
|
||||||
|
|
||||||
|
# Cache compartilhado (inicializado no async_pre_call_hook)
|
||||||
|
self._dual_cache: Optional[DualCache] = None
|
||||||
|
self._embedder: Optional[FastEmbedder] = None
|
||||||
|
|
||||||
|
# Cache local de embeddings de tools
|
||||||
|
self._tool_embeddings: Dict[str, List[float]] = {}
|
||||||
|
self._tool_texts: Dict[str, str] = {}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"CustomToolRouter initialized: "
|
||||||
|
f"top_k={top_k}, min_similarity={min_similarity}, "
|
||||||
|
f"mode={similarity_mode}, model={embedding_model}, "
|
||||||
|
f"always_include={self.always_include_tools}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_user_message(self, messages: List[Dict]) -> str:
|
||||||
|
"""Extrai a última mensagem do usuário."""
|
||||||
|
for msg in reversed(messages):
|
||||||
|
role = msg.get("role", "")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if role == "user":
|
||||||
|
if isinstance(content, list):
|
||||||
|
# Pode ser multimodal - extrai texto
|
||||||
|
texts = []
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text":
|
||||||
|
texts.append(part.get("text", ""))
|
||||||
|
return " ".join(texts)
|
||||||
|
return str(content)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _build_tool_text(self, tool: Dict) -> str:
|
||||||
|
"""Cria um texto combinado para a tool (nome + descrição + parâmetros)."""
|
||||||
|
func = tool.get("function", tool)
|
||||||
|
name = func.get("name", "")
|
||||||
|
description = func.get("description", "")
|
||||||
|
parameters = func.get("parameters", {})
|
||||||
|
|
||||||
|
# Concatena informações relevantes
|
||||||
|
parts = [name, description]
|
||||||
|
|
||||||
|
# Adiciona nomes dos parâmetros
|
||||||
|
if isinstance(parameters, dict):
|
||||||
|
props = parameters.get("properties", {})
|
||||||
|
if isinstance(props, dict):
|
||||||
|
param_names = " ".join(props.keys())
|
||||||
|
if param_names:
|
||||||
|
parts.append(param_names)
|
||||||
|
|
||||||
|
return " ".join(parts).lower()
|
||||||
|
|
||||||
|
async def _build_tool_index(self, tools: List[Dict]):
|
||||||
|
"""Indexa as tools: gera/cacheia embeddings."""
|
||||||
|
texts = {}
|
||||||
|
for tool in tools:
|
||||||
|
func = tool.get("function", tool)
|
||||||
|
name = func.get("name", "")
|
||||||
|
text = self._build_tool_text(tool)
|
||||||
|
texts[name] = text
|
||||||
|
self._tool_texts[name] = text
|
||||||
|
|
||||||
|
# Gera embeddings para tools ainda não indexadas
|
||||||
|
if self.similarity_mode == "embedding" and self._embedder:
|
||||||
|
new_tools = [name for name in texts if name not in self._tool_embeddings]
|
||||||
|
if new_tools:
|
||||||
|
tool_texts_list = [texts[name] for name in new_tools]
|
||||||
|
try:
|
||||||
|
embeddings = await self._embedder.embed(tool_texts_list)
|
||||||
|
for name, emb in zip(new_tools, embeddings):
|
||||||
|
self._tool_embeddings[name] = emb
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Tool indexing failed, using jaccard: {e}")
|
||||||
|
self.similarity_mode = "jaccard"
|
||||||
|
|
||||||
|
async def _compute_similarities(
|
||||||
|
self, query: str, tool_names: List[str]
|
||||||
|
) -> List[Tuple[str, float]]:
|
||||||
|
"""Calcula similaridade entre query e cada tool."""
|
||||||
|
if not query.strip():
|
||||||
|
return [(name, 0.0) for name in tool_names]
|
||||||
|
|
||||||
|
if self.similarity_mode == "embedding" and self._embedder:
|
||||||
|
try:
|
||||||
|
query_emb = (await self._embedder.embed([query]))[0]
|
||||||
|
similarities = []
|
||||||
|
for name in tool_names:
|
||||||
|
tool_emb = self._tool_embeddings.get(name)
|
||||||
|
if tool_emb:
|
||||||
|
sim = cosine_similarity([query_emb], [tool_emb])[0][0]
|
||||||
|
similarities.append((name, float(sim)))
|
||||||
|
else:
|
||||||
|
similarities.append((name, 0.0))
|
||||||
|
return similarities
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Embedding similarity failed: {e}")
|
||||||
|
# fallback pra jaccard
|
||||||
|
|
||||||
|
# Similaridade combinada (fallback inteligente)
|
||||||
|
similarities = []
|
||||||
|
for name in tool_names:
|
||||||
|
tool_text = self._tool_texts.get(name, name)
|
||||||
|
sim = _combined_text_similarity(query, tool_text)
|
||||||
|
similarities.append((name, sim))
|
||||||
|
|
||||||
|
return similarities
|
||||||
|
|
||||||
|
async def async_pre_call_hook(
|
||||||
|
self,
|
||||||
|
user_api_key_dict: UserAPIKeyAuth,
|
||||||
|
cache: DualCache,
|
||||||
|
data: dict,
|
||||||
|
call_type: str,
|
||||||
|
) -> Optional[Union[Exception, str, dict]]:
|
||||||
|
"""
|
||||||
|
Hook principal: intercepta a requisição e filtra tools.
|
||||||
|
|
||||||
|
Retorna:
|
||||||
|
- dict modificado → segue com as tools filtradas
|
||||||
|
- None → continua sem modificação
|
||||||
|
- Exception → rejeita a request
|
||||||
|
"""
|
||||||
|
# Só processa completion
|
||||||
|
if call_type not in ("completion", "acompletion"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
messages = data.get("messages", [])
|
||||||
|
tools = data.get("tools", [])
|
||||||
|
|
||||||
|
# Se não tem tools ou só 1, não precisa fazer nada
|
||||||
|
if not tools or len(tools) <= 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Inicializa cache e embedder
|
||||||
|
self._dual_cache = cache
|
||||||
|
if self._embedder is None:
|
||||||
|
self._embedder = FastEmbedder(
|
||||||
|
embedding_model=self.embedding_model,
|
||||||
|
cache=cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extrai query do usuário
|
||||||
|
user_query = self._extract_user_message(messages)
|
||||||
|
|
||||||
|
# Indexa as tools (se necessário)
|
||||||
|
await self._build_tool_index(tools)
|
||||||
|
|
||||||
|
# Nomes de todas as tools
|
||||||
|
all_tool_names = []
|
||||||
|
for tool in tools:
|
||||||
|
func = tool.get("function", tool)
|
||||||
|
all_tool_names.append(func.get("name", ""))
|
||||||
|
|
||||||
|
# Calcula similaridades
|
||||||
|
similarities = await self._compute_similarities(user_query, all_tool_names)
|
||||||
|
|
||||||
|
# Filtra: sempre inclui + top-k por similaridade
|
||||||
|
selected_names = set()
|
||||||
|
|
||||||
|
# 1. Tools always-include
|
||||||
|
for name in self.always_include_tools:
|
||||||
|
if name in all_tool_names:
|
||||||
|
selected_names.add(name)
|
||||||
|
|
||||||
|
# 2. Top-k por similaridade (acima do threshold)
|
||||||
|
sorted_tools = sorted(similarities, key=lambda x: x[1], reverse=True)
|
||||||
|
for name, sim in sorted_tools:
|
||||||
|
if len(selected_names) >= self.top_k:
|
||||||
|
break
|
||||||
|
if sim >= self.min_similarity:
|
||||||
|
selected_names.add(name)
|
||||||
|
|
||||||
|
# Se não selecionou nada, pelo menos a top-1
|
||||||
|
if not selected_names and sorted_tools:
|
||||||
|
selected_names.add(sorted_tools[0][0])
|
||||||
|
|
||||||
|
# Monta novo tools array
|
||||||
|
new_tools = [t for t in tools if (
|
||||||
|
t.get("function", t).get("name", "") in selected_names
|
||||||
|
)]
|
||||||
|
|
||||||
|
# Log do que aconteceu
|
||||||
|
logger.info(
|
||||||
|
f"ToolRouter: tools {len(tools)}→{len(new_tools)} "
|
||||||
|
f"(query: '{user_query[:60]}...' ) "
|
||||||
|
f"selected: {sorted(selected_names)} "
|
||||||
|
f"similarities: {dict(sorted_tools)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retorna dados modificados
|
||||||
|
if len(new_tools) < len(tools):
|
||||||
|
data["tools"] = new_tools
|
||||||
|
# Se reduziu drasticamente, também podemos avisar o modelo
|
||||||
|
if len(new_tools) <= 1 and messages:
|
||||||
|
system_msg = (
|
||||||
|
"Você tem acesso apenas a tools selecionadas para esta conversa. "
|
||||||
|
"Se precisar de uma tool específica que não está disponível, "
|
||||||
|
"explique qual precisa e o usuário poderá liberá-la."
|
||||||
|
)
|
||||||
|
# Adiciona system message sutil, sem poluir
|
||||||
|
existing_system = False
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("role") == "system":
|
||||||
|
existing_system = True
|
||||||
|
if system_msg not in msg.get("content", ""):
|
||||||
|
msg["content"] = msg.get("content", "") + "\n\n" + system_msg
|
||||||
|
break
|
||||||
|
if not existing_system:
|
||||||
|
messages.insert(0, {"role": "system", "content": system_msg})
|
||||||
|
data["messages"] = messages
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def async_log_success_event(self, *args, **kwargs):
|
||||||
|
"""Apenas para logging/monitoramento."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Factory para criação do logger via string path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Instância pronta + factory + wrapper de guardrail
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Uso como CALLBACK (recomendado — roda em todas as chamadas):
|
||||||
|
# litellm_settings:
|
||||||
|
# callbacks: ["tool_router_logger.tool_router_instance"]
|
||||||
|
#
|
||||||
|
# Uso como GUARDRAIL (ativa por request com `guardrails: ["tool_router"]`):
|
||||||
|
# litellm_settings:
|
||||||
|
# guardrails:
|
||||||
|
# - guardrail_name: tool_router
|
||||||
|
# litellm_params:
|
||||||
|
# guardrail: tool_router_logger.ToolRouterGuardrail
|
||||||
|
# mode: pre_call
|
||||||
|
# default_on: false
|
||||||
|
# top_k: 3
|
||||||
|
# min_similarity: 0.15
|
||||||
|
# similarity_mode: jaccard
|
||||||
|
#
|
||||||
|
# Parâmetros configuráveis via env vars:
|
||||||
|
# TOOL_ROUTER_EMBEDDING_MODEL, TOOL_ROUTER_TOP_K, TOOL_ROUTER_MIN_SIMILARITY,
|
||||||
|
# TOOL_ROUTER_ALWAYS_INCLUDE (csv), TOOL_ROUTER_SIMILARITY_MODE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _router_params_from_env() -> dict:
|
||||||
|
"""Lê os parâmetros do router a partir de env vars (com defaults sãos)."""
|
||||||
|
return {
|
||||||
|
"embedding_model": os.environ.get("TOOL_ROUTER_EMBEDDING_MODEL", "text-embedding-3-small"),
|
||||||
|
"top_k": int(os.environ.get("TOOL_ROUTER_TOP_K", "3")),
|
||||||
|
"min_similarity": float(os.environ.get("TOOL_ROUTER_MIN_SIMILARITY", "0.15")),
|
||||||
|
"always_include_tools": set(
|
||||||
|
filter(None, os.environ.get("TOOL_ROUTER_ALWAYS_INCLUDE", "get_time,help,list_tools").split(","))
|
||||||
|
),
|
||||||
|
"similarity_mode": os.environ.get("TOOL_ROUTER_SIMILARITY_MODE", "jaccard"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Instância global reutilizável — é o que o proxy carrega via:
|
||||||
|
# callbacks: ["tool_router_logger.tool_router_instance"]
|
||||||
|
tool_router_instance: CustomToolRouter = CustomToolRouter(**_router_params_from_env())
|
||||||
|
|
||||||
|
|
||||||
|
def get_custom_tool_router(**kwargs) -> CustomToolRouter:
|
||||||
|
"""
|
||||||
|
Factory function para uso programático.
|
||||||
|
|
||||||
|
Se nenhum kwarg for passado, retorna a instância global
|
||||||
|
(equivalente a `tool_router_instance`). Se kwargs forem passados,
|
||||||
|
cria uma instância dedicada com aqueles parâmetros.
|
||||||
|
"""
|
||||||
|
global tool_router_instance
|
||||||
|
if kwargs:
|
||||||
|
return CustomToolRouter(**kwargs)
|
||||||
|
return tool_router_instance
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRouterGuardrail(CustomGuardrail):
|
||||||
|
"""
|
||||||
|
Wrapper para usar o ToolRouter como guardrail do LiteLLM Proxy.
|
||||||
|
|
||||||
|
Guardrails permitem ativar o roteamento de tools por request:
|
||||||
|
curl ... -d '{"model": "gpt-4o-mini", "guardrails": ["tool_router"], ...}'
|
||||||
|
|
||||||
|
Ou globalmente com `default_on: true` no config.yaml.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ROUTER_PARAMS = (
|
||||||
|
"embedding_model", "top_k", "min_similarity",
|
||||||
|
"always_include_tools", "similarity_mode",
|
||||||
|
"cache_host", "cache_port", "cache_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, guardrail_name: str = "tool_router", **kwargs):
|
||||||
|
# `guardrail_name`, `event_hook`, `default_on` e demais campos do
|
||||||
|
# guardrail são repassados à base; os parâmetros do router são
|
||||||
|
# extraídos para criar a instância interna.
|
||||||
|
super().__init__(guardrail_name=guardrail_name, **kwargs)
|
||||||
|
router_params = {k: v for k, v in kwargs.items() if k in self._ROUTER_PARAMS}
|
||||||
|
self._router = CustomToolRouter(**router_params)
|
||||||
|
|
||||||
|
async def async_pre_call_hook(
|
||||||
|
self,
|
||||||
|
user_api_key_dict: UserAPIKeyAuth,
|
||||||
|
cache: DualCache,
|
||||||
|
data: dict,
|
||||||
|
call_type: str,
|
||||||
|
) -> Optional[Union[Exception, str, dict]]:
|
||||||
|
"""Delega o filtro de tools ao CustomToolRouter."""
|
||||||
|
return await self._router.async_pre_call_hook(
|
||||||
|
user_api_key_dict=user_api_key_dict,
|
||||||
|
cache=cache,
|
||||||
|
data=data,
|
||||||
|
call_type=call_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_log_success_event(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user