Guia técnico do repositório: arquitetura, comandos, autenticação, roteamento, gravações, migrações, testes, CI, releases e o fluxo de trabalho com agentes. Para o uso do produto, veja docs/usuario/. A terminologia de domínio está em terminologia.md. As decisões do projeto estão em decisoes-iniciais.md e em docs/adr/.
O Thinker começou como um snapshot sem histórico de mnfst/llm-gateway (manifest@6.28.6) e evolui de forma independente; não há obrigação de compatibilidade com o Manifest. Convenções de nome: variáveis THINKER_*, prefixo de chave thk_, CLI thinker, imagem tmsoftbrasil/thinker, porta 2099. Nunca crie identificadores novos com manifest/mnfst.
Língua: código, identificadores, comentários, commits e PRs em inglês; documentação em
docs/e notas de release em português. O dashboard tem inglês (referência) e português do Brasil desde a v0.3 (#42); veja Traduções do dashboard.
O Thinker é um gateway de LLM auto-hospedado. Ele fica entre agentes ("harnesses") e provedores de inferência, escolhe o modelo de cada requisição e registra custo, tokens e corpo das chamadas. No dashboard, "Harness" é o nome de um agente (rotas /harnesses); no backend, no banco e na API continua agent.
Três superfícies de gestão compartilham a mesma API REST (/api/v1/*): o dashboard, a CLI thinker (packages/cli/) e o servidor MCP remoto (packages/backend/src/mcp/). Adicione a capacidade uma vez na camada de serviço e exponha onde precisar; nunca duplique a lógica por superfície.
A imagem Docker traz a CLI no PATH (/app/cli-bin/thinker, lançador com shebang #!/nodejs/bin/node, já que a imagem distroless não tem shell nem /usr/bin/env; /app/thinker é o mesmo arquivo, para docker run <imagem> thinker ..., já que o entrypoint é o node).
Credenciais de gestão (API REST, CLI e MCP), resolvidas por common/services/api-key-auth.service.ts para o ApiKeyGuard e para o McpController:
- Chaves de API (
thk_adm_…): criadas por owner/admin em Organização → Chaves de API ou comthinker key create --name <n> [--expires-days <d>](/api/v1/api-keys,auth/api-keys.controller.ts). Por padrão nunca expiram; a expiração escolhida é fixa (não desliza). Valem noX-API-Keyda API e no MCP (X-API-KeyouAuthorization: Bearer thk_adm_…, com escopos de leitura e escrita), com o papel atual de quem criou. - Tokens do
thinker login(thk_pat_…, nomecli): deslizam até o teto absoluto, como antes; também valem no MCP. - Token root (
thk_root_v1.<ts>.<email>.<hmac>,common/utils/root-token.tsepackages/cli/src/root-token.ts, com vetor de teste comum): a CLI o gera sozinha quando não há outra credencial eTHINKER_ENCRYPTION_KEYestá no ambiente (o próprio contêiner, oudocker runcom a mesma variável), e só para uma origem de loopback (localhost,127.0.0.0/8,::1): o token não tem audiência e poderia ser reaproveitado contra esta instância, então outro host nunca o recebe, salvo comTHINKER_ROOT_REMOTE=1. Nesse ambiente, sem--url/THINKER_URL, a CLI mirahttp://localhost:$PORT. HMAC derivado daTHINKER_ENCRYPTION_KEY(aceita também a_PREVIOUS), janela de 5 min. Age como o admin da instância mais antigo, com papelowner(THINKER_ROOT_USER=<email>escolhe outra conta;x-thinker-organizationescolhe a organização). Quem tem essa chave já decifra tudo e mexe no banco, então ela basta como prova de root. - Log: toda requisição feita com essas credenciais gera, ao terminar, uma linha
[ManagementAudit] Management API|MCP request: <método> <caminho> status=… credential=api_key|root key=<nome> key_id=… user=… tenant=… client_ip=…; criar e revogar chave também (API key created|revoked). As mutações com@Auditgravamcredential,key_nameekey_idnometadatado alerta de auditoria.
Uso em produção: docker exec thinker-app thinker proxy server list (sem chave). De outro contêiner: docker run --rm --network container:<app> --env-file <(docker inspect <app> --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^THINKER_ENCRYPTION_KEY=') tmsoftbrasil/thinker thinker <comando>. Fora do servidor: THINKER_URL + THINKER_API_KEY=thk_adm_… ou thinker login.
Monorepo com npm workspaces e Turborepo:
packages/
├── shared/ thinker-shared tipos e helpers usados por backend, frontend e CLI (provedores, tiers, snippets)
├── backend/ thinker-backend NestJS 12, TypeORM 1.1, PostgreSQL 18, Better Auth
├── frontend/ thinker-frontend React 19 + Vite + Tailwind 4, dashboard
├── cli/ thinker-cli CLI `thinker` (privada, não publicada)
└── thinker/ thinker pacote sem código que guarda a versão canônica
contracts/gateway/ testes de contrato HTTP do gateway
design-system/ registro do design system (gerado)
deploy/ templates de deploy da comunidade
docker/ Dockerfile, docker-compose.yml, install.sh, .env.example
Em produção o NestJS serve a API e os arquivos estáticos do frontend na mesma porta (um serviço só). Em desenvolvimento, o Vite roda na :3000 e faz proxy de /api para o backend na :3001.
Módulos do backend (packages/backend/src/):
| Pasta | Função |
|---|---|
auth/ |
Better Auth, guards de sessão, política de cadastro, login da CLI (PKCE), GET /api/v1/me |
setup/ |
Assistente do primeiro administrador e token de setup |
database/ |
Configuração do TypeORM, migrações, sincronização de preços, retenção, re-cifragem |
entities/ |
Entidades TypeORM |
common/ |
Guards, DTOs, filtros, erros (M###), cifragem, validação de URL/SSRF, serviços de gravação |
routing/ |
Provedores, tiers, resolução, proxy (/v1/*), fallback, OAuth de assinaturas, provedores customizados, modelos virtuais |
scoring/ |
Pontuação de complexidade das requisições |
model-discovery/, model-prices/ |
Descoberta de modelos por provedor e preços |
analytics/ |
Análises do dashboard (visão geral, tokens, custos, requisições) |
notifications/ |
Regras de limite (alerta e bloqueio); a entrega vai pelos gatilhos |
triggers/ |
Gatilhos de eventos: cadastro, vínculos, fila, worker e envio (e-mail, webhook, Telegram, Syslog) |
playground/, sse/, health/ |
Playground, eventos em tempo real, health check |
mcp/ |
Servidor MCP remoto (OAuth 2.1, uma instância por POST, ferramentas em tools/) |
otlp/ |
Autenticação por chave de agente (thk_); o ingest OTLP responde 410 |
Fonte única dos provedores: SHARED_PROVIDERS em packages/shared/src/ (reexportado como PROVIDER_REGISTRY em common/constants/providers.ts). Não repita nomes de provedores em outro lugar. Para adicionar um provedor: entrada em SHARED_PROVIDERS; FetcherConfig em model-discovery/provider-model-fetcher.service.ts; ProviderEndpoint em routing/proxy/provider-endpoints.ts; ProviderDef em packages/frontend/src/services/providers.ts.
Pré-requisito: Node.js (a CI usa a 24) e Docker para os testes e2e.
npm ci
npm run build --workspace=packages/shared # o shared precisa estar compilado antes dos outros
npm run build # turbo: frontend (Vite) e backend (Nest)
npm start # node packages/backend/dist/main.js (API + frontend)
npm run lint
npm run typecheck --workspaces --if-present # TypeScript 6 (tsc6)
npm run typecheck:ts7 # TypeScript 7 (tsgo), só --noEmit
npm test # turbo test, todos os pacotes
npm run test:scripts # testes dos scripts do repositórioO repositório tem dois compiladores lado a lado (#87, opção B):
Pacote no package.json |
Instala | Binário | Uso |
|---|---|---|---|
typescript = npm:@typescript/typescript6@^6.0.2 |
TypeScript 6 (o @typescript/typescript6 reexporta o typescript@6 instalado como @typescript/old) |
tsc6 |
emissão (shared, cli), nest build, ts-jest, typescript-eslint, ts-node (migrações e backfills) e o typecheck |
@typescript/native = npm:typescript@^7.0.2 (só na raiz) |
TypeScript 7, o compilador nativo (tsgo) |
via scripts/tsgo.mjs |
só typecheck:ts7, sempre com --noEmit |
Nunca chame tsc puro. O typescript@7 e o typescript@6 (@typescript/old) declaram os dois um binário tsc, e o npm liga em node_modules/.bin/tsc o que processar por último; o resultado muda de uma instalação para outra. Use tsc6 para o TypeScript 6 e node scripts/tsgo.mjs ... --noEmit para o 7 (o script recusa rodar sem --noEmit e confere que achou a versão 7). Os dois leem os mesmos tsconfig*.json. O job Typecheck (TypeScript 7) da CI roda npm run typecheck:ts7.
O @typescript/native é devDependency da raiz, então o --omit=dev não o leva para a imagem. O TypeScript 6 entra na árvore de produção como peer do ts-node (via typeorm), e o estágio prod-deps do docker/Dockerfile apaga node_modules/typescript e node_modules/@typescript.
Adiado do TypeScript 7 (#87; acompanhamento em #230):
- Emitir JS com o
tsgo(opção C, descartada): poria na imagem um JS de outro compilador, com risco alto e pouco ganho. O build continua notsc6e nonest build. - Ferramentas no TS 7: o TypeScript 7.0 não tem API programática (a nova chega no 7.1 e é diferente da atual). Por isso
ts-jest(peertypescript <7),typescript-eslint(peer<6.1.0),ts-node(usa a API) e o@nestjs/cli(depende detypescript ~6.0.2) continuam no TS 6 pelo alias. - Critérios de reabertura: API do TS 7.1 estável;
ts-jest,typescript-eslinte@nestjs/clicom suporte declarado ao 7; um substituto para ots-nodenas migrações e backfills. Trocarts-jestpor Vitest/SWC outs-nodeportsxsó para viabilizar o TS 7 vira issue própria. - Dependabot e aliases
npm:: não confirmado se o Dependabot atualiza dependências declaradas como alias (npm:pacote@faixa). Fica para #97 conferir e, se preciso, ajustar a entrada dotypescriptno.github/dependabot.yml.
O backend precisa de um packages/backend/.env com, no mínimo, BETTER_AUTH_SECRET e THINKER_ENCRYPTION_KEY (32+ caracteres, valores diferentes) e DATABASE_URL. O auth.instance.ts lê process.env na importação, antes do ConfigModule, por isso o .env é pré-carregado pela opção --env-file do nest start (o Node lê o arquivo nativamente; o projeto não depende do dotenv):
# backend
cd packages/backend && npx nest start --watch --env-file .env
# frontend (em outro terminal)
cd packages/frontend && npx vitenpm run dev (turbo) sobe só o frontend. .env mínimo:
PORT=3001
BIND_ADDRESS=127.0.0.1
NODE_ENV=development
BETTER_AUTH_SECRET=<openssl rand -hex 32>
THINKER_ENCRYPTION_KEY=<outro openssl rand -hex 32>
DATABASE_URL=postgresql://usuario:senha@localhost:PORTA/thinker_dev_<aleatorio>
SEED_DATA=trueUse um banco novo por sessão e dê a ele um nome único (thinker_<contexto>_<aleatório>), para instâncias concorrentes não poluírem os dados umas das outras. Suba um PostgreSQL descartável pelo Docker numa porta livre (não use as portas 5432/5433 nem contêineres de outros projetos). SEED_DATA=true (só em dev/teste; ignorado com NODE_ENV=production) cria o admin admin@thinker.example.com / thinker123, o harness demo-agent e dados de exemplo.
| Escopo | Comando |
|---|---|
| Backend (Jest) | npm test --workspace=packages/backend |
| Frontend (Vitest) | npm test --workspace=packages/frontend |
| Shared | npm test --workspace=packages/shared |
| CLI | npm test --workspace=packages/cli |
| Backend e2e (Docker) | npm run test:e2e:docker --workspace=packages/backend |
| Contratos do gateway | THINKER_BASE_URL=http://127.0.0.1:2099 node --test contracts/gateway/tests/*.test.mjs |
NestJS 12 é só ESM. O backend continua CommonJS e carrega os @nestjs/* por require(esm), que o Node faz nativamente (20.19+, 22.12+ e 24). No Jest, isso depende da API vm de módulos, que fica atrás de --experimental-vm-modules; por isso os scripts test, test:watch e test:e2e do backend acrescentam essa flag ao NODE_OPTIONS. Rode o Jest sempre por esses scripts (ou passe a flag você mesmo): npx jest puro falha com Must use import to load ES Module. Pelo guia oficial, a CLI e os schematics do Nest 12 pedem Node 24.15+ (as imagens fixadas no docker/Dockerfile usam a 24.22).
TypeORM 1.x: null/undefined no where lança erro. As APIs de alto nível (find*, count*, exists*, update, delete, setFindOptions) recusam null ou undefined num filtro, em vez de ignorar a propriedade como a 0.3 fazia (o que transformava um tenant_id ausente em "sem filtro"). A regra está fixada em INVALID_WHERE_VALUES_BEHAVIOR (database/data-source-definitions.ts) e vale para o app, o CLI de migrações e o e2e; nunca a troque por ignore. Para buscar NULL, use IsNull(); para um filtro opcional, só inclua a chave quando houver valor. O .where() do QueryBuilder não passa por essa checagem. select e relations usam só a forma de objeto ({ id: true }).
Os CLIs avulsos (migrações e backfills) carregam o .env com process.loadEnvFile() em src/database/load-env-file.ts; o projeto não depende do dotenv.
E2E só por test:e2e:docker. A suíte usa dropSchema: true (apaga o banco). O script packages/backend/scripts/test-e2e-docker.mjs escolhe uma porta livre em 127.0.0.1, sobe um postgres:18-alpine com nome único e dados em tmpfs montado em /var/lib/postgresql (banco thinker_e2e_test), roda as migrações, executa a suíte em série e remove o contêiner no fim (sucesso, falha ou Ctrl-C). A imagem é a mesma, com o mesmo digest, de docker/docker-compose.yml e do job Backend e2e (PostgreSQL) da CI (THINKER_E2E_PG_IMAGE troca a imagem). Ao atualizar o digest, atualize os três lugares e deploy/coolify/docker-compose.yml. Argumentos extras vão para o Jest:
npm run test:e2e:docker --workspace=packages/backend -- test/limits.e2e-spec.ts
TZ=America/Sao_Paulo npm run test:e2e:docker --workspace=packages/backendUma proteção (packages/backend/test/e2e-db-guard.ts) recusa rodar sem DATABASE_URL, sem THINKER_E2E_ALLOW_DROP=1 ou se o nome do banco não tiver test como token separado. Um teste de varredura (e2e-db-guard-scan.spec.ts) falha se um spec novo apagar o banco sem passar pela proteção, usar DATABASE_URL ?? ... ou citar localhost:5432. Os testes e2e nunca falam com a internet. Para um PostgreSQL seu: DATABASE_URL=postgresql://.../thinker_e2e_test THINKER_E2E_ALLOW_DROP=1 npm run test:e2e --workspace=packages/backend, nunca contra um banco de desenvolvimento.
Qualquer mudança de comportamento em tempo de execução também passa pelo agente qa-docker, contra a imagem construída localmente.
Teste completo (só quando solicitado). npm run test:full (ou scripts/test-full.sh) roda, em ordem: lint, catálogos i18n, registro do design system e typecheck (TypeScript 6 e 7); build de todos os pacotes com checagem de arquivos gerados; testes unitários (backend em UTC e em America/Sao_Paulo, frontend, shared, CLI); testes dos scripts; e2e do backend num PostgreSQL descartável nos dois fusos; e os contratos do gateway quando THINKER_BASE_URL aponta para uma instância de teste. Ele nunca roda do ship.sh. Opções: --only <estágios>, --skip <estágios>, --fail-fast; os logs de cada passo ficam num diretório temporário impresso no resumo. Antes de uma entrega para ambiente crítico, rode também o qa-docker na imagem construída. O mapa de cada recurso para os testes que o cobrem está em matriz-de-testes.md; ao criar um recurso, acrescente a linha dele lá.
O dashboard é React 19 com react-router 7, Vite e Tailwind 4 (ADR 0007). As peças que toda tela usa:
- Shell (
src/App.tsx,src/components/shell/): sidebar com ícones e labels, recolhível pelo botão à direita da logomarca, que toma o lugar dela quando recolhida (a escolha fica emlocalStorage,thinker.sidebarCollapsed; abaixo de 900 px vira gaveta); barra superior com o título da página e o ícone do menu (activeNavItem, que também cobre as páginas do menu do usuário emUSER_MENU_PAGES), a versão e os ícones Documentação, Procurar, Alterar tema e Alertas. As páginas não repetem o título no conteúdo: opage-headsó aparece quando há ações; menu do usuário no rodapé da sidebar, com a organização atual, a troca e a criação de organizações, membros, administração de usuários, integrações (MCP e CLI), preferências da conta e sair. - Menu principal: um arquivo só,
src/config/navigation.tsx. A sidebar, a trilha e o "Ir para" da paleta ⌘K leem dele. - Paleta ⌘K (
CommandPalette.tsx): páginas e ações filtradas no navegador, maisGET /api/v1/search?q=(mínimo 2 caracteres), que procura na organização ativa harnesses, conexões, provedores custom, modelos virtuais, modelos do catálogo e membros (backend/src/console/). - Alertas (
AlertsMenu.tsx):GET /api/v1/alerts, as requisições com falha das últimas 24 horas. O contador mostra as mais novas que a última abertura, guardada no navegador (thinker.alertsSeenAt); cada item abre a requisição em/messages?request=<id>. - Tema: claro, escuro ou o do sistema (
src/lib/theme.ts, chavethemenolocalStorage, aplicada antes da pintura porpublic/theme-init.js). O ícone do topo alterna entre claro e escuro; Preferências da conta oferece também "sistema". - Estado e dados: stores globais são
signal()comuseSignal()(src/lib/signal.ts); telas carregam dados comuseResource(fetcher, deps)(src/lib/use-resource.ts), que descarta respostas atrasadas. Os pings do SSE (services/sse.ts) entram como dependência para recarregar. - Estilo: tokens por papel em
src/styles/tokens.css(fundos, fios, quatro níveis de texto, um acento azul, estados, portas e séries de gráfico, nos dois temas) e as classes debase.css/shell.css. Componentes usam os primitivos desrc/components/ui.tsxe utilitários Tailwind ligados aos tokens (bg-elev,text-muted,border-line...); nada de hex nem dedark:para cor. - Fluxo (React Flow):
src/components/flow/traz o palco (FlowCanvas), os nós no estilo do n8n (source: ponto de entrada com o texto dentro e o lado esquerdo mais arredondado;node: ícone dentro de um quadrado e o nome embaixo), as arestas (curvas ligando bolinha a bolinha, com seta só na entrada IP → harness, de tamanho fixo em pixels; pulsos quando há tráfego, tracejadas para fallback, vermelhas quando metade ou mais falhou; espessura pelo tráfego do último minuto, da base na ligação mais calma até 4x na mais movimentada) e o layout em colunas (columnLayout: uma coluna por tipo, a 396 px uma da outra, ordenada pelos pais para evitar cruzamentos; posições salvas têm prioridade; o arraste encaixa em meia grade,DRAG_SNAP= 12 px). Nos controles de zoom, o quarto botão alterna o arraste no espaço vazio entre mover o palco (hand, padrão) e selecionar vários nós com uma caixa para arrastá-los juntos (square-dashed-mouse-pointer; o botão do meio e o direito continuam movendo o palco); a seleção sobrevive à recarga dos dados. A página/topologyusa o palco e carrega num chunk próprio. - Topologia (
pages/Topology.tsxepages/topology-adapter.ts):GET /api/v1/topology?range=<n>m|<n>h|<n>d(1 a 120 min, 1 a 24 h ou 1 a 60 dias,topologyMinutes; fora disso vale 24h),range=interval&from=…&to=…(instantes ISO; conta as tentativas que começaram ou terminaram entre eles) ourange=live, que troca a janela pelas tentativaspendinge as encerradas há até 3 s (timestamp + duration_ms; pendentes com mais de 1 h ficam de fora) e fazrecentcontar as que ainda aguardam resposta; os IPs vêm da tabelarequests, gravadapendingna entrada (console/topology.service.ts) devolve, só da organização ativa, o caminho da requisição: IPs dos clientes → harnesses → conexões de provedor (uma por conta: chave, assinatura ou servidor local; abaixo do ícone, o ícone do tipo no menu lateral) → perfis de proxy de saída (nó "Proxy Switch", íconewaypoints) → servidores de proxy (íconehand-platter, nome ehost:porta) → saída. A saída tem três nós redondos, por serem o fim da cadeia: "Internet" (globe), a internet à frente dos proxies; "Local Gateway" (globe-lock), o acesso direto da própria instância, usado por conexões sem proxy e pelo item Direct da cadeia de um perfil; e "Rede local", para servidores locais. Depois da saída vem a coluna Destino (destinations, nósdest:<host>, íconemap-pin): um nó por hostname (com a porta quando não é a padrão,urlHost), compartilhado pelas contas que usam o mesmo host. O host vem da URL base da conta (tenant_providers.base_url), senão dobase_urldo custom provider ou servidor local, senão do endpoint embutido do provedor com a região (defaultAccountBaseUrl). Conexão sem proxy liga a sua saída ao destino; com perfil, as tentativasdirectsaem pelo Local Gateway e o resto pela Internet. O harness liga nas conexões que servem a rota principal e os fallbacks dos seus modelos virtuais habilitados (keyLabelfixa a conexão; sem ele, todas as ativas do provedor e tipo de autenticação); o resto (auto, tiers, especificidade) só aparece com tráfego na janela, salvo num harness sem tráfego, que mostra as rotas auto configuradas para nunca ficar sem ligação, atribuído pelatenant_provider_iddeagent_messages. Cada aresta harness → conexão traz os modelos (via: modelo virtual ouauto→ modelo final). A saída da conexão segue oProxyEgressService: atribuição própria, senão o padrão do provedor (<tipo>:<provedor>, servidores locais pelo tipo), senão global; um perfil com itens vira o nó do perfil, e global comTHINKER_HTTP_PROXYvira o nó "Global" (servidores locais nunca usam o global). O perfil liga em cada servidor de proxy da sua cadeia (um nó por servidor, compartilhado entre perfis), cada servidor liga na Internet, e o perfil liga no Local Gateway quando tem o item Direct; o nó Global liga direto na Internet. O tráfego de cada linha vem de onde cada tentativa saiu (agent_messages.egress:directconta para o Local Gateway,proxypara o servidor emproxy_id); tentativas sem esse registro caem no primeiro servidor habilitado da cadeia. Hops que falharam (egress_hopscom resultado diferente deokeskipped_disabled) contam como erro no servidor e na ligação perfil → servidor, nunca na ligação servidor → Internet. Os IPs vêm decaller_attribution.clientIp(gravado pelo proxy viacommon/utils/client-ip.ts, comTRUSTED_PROXIES); IPv6 aparece expandido (sem compressão, com zeros à esquerda) em duas linhas de quatro grupos, em fonte menor para caber na largura do IPv4, e IPv4 mapeado aparece como IPv4. Os nós são arrastáveis:PUT /api/v1/topology/positionsgrava as posições por workspace na tabelatopology_positions(papelmember), oGETas devolve empositions, eDELETE /api/v1/topology/positions("Reorganizar", botãobroom-sparklesnos controles do palco, com confirmação num modal) volta ao layout em colunas. Cada nó e ligação traz tambémrecent, as requisições do último minuto (fixo, qualquer que seja o período), que definem a espessura da linha; a página recarrega a cada 30 s para a espessura acompanhar. A borda dos nós fica azul com tráfego, vermelha com qualquer erro no período e neutra (tracejada) sem tráfego. Os nós de IP têm largura fixa (a do IPv4 mais longo, mais 10% e 5 px), com o ícone alinhado ao topo da primeira linha. Ligações sem tráfego na janela ficam cinza, tracejadas e semitransparentes ("Sem uso"). Métricas ficam no hover (nós e arestas) e na contagem sobre a aresta. A página recarrega a cada nova requisição (SSE), tem, alinhado à esquerda, o select "Filtro" (Recente: todo o diagrama no período; Tráfego: só nós e ligações com tráfego; Tempo real: todo o diagrama, com só as linhas de requisições em andamento acesas e a contagem das que aguardam resposta, consultando a cada 1 s e sem o slider; Intervalo: dois campos de data e hora com segundos, que no primeiro clique vêm do primeiro segundo do dia até agora;thinker.topologyMode), escolhe o período com um select de unidade (minutos, horas, dias) e um slider com 25% da largura da barra, guarda o período no navegador (thinker.topologyRange), na barra compacta acima do palco (sem texto de ajuda), e abre o objeto com clique duplo. - Cadeias de rotas no Roteamento (
RouteChainCard.tsx,ModelChainManager.tsx): os cards doauto(tierdefault,pages/AutoChainCard.tsx) e dos modelos virtuais são só leitura (fluxo de cima para baixo, conta e preço resumido). Toda edição fica na janela flutuante "Gerenciar": à esquerda o fluxo (principal e até 5 fallbacks, reordenável, com os parâmetros por modelo), à direita o catálogo agrupado por conta, com filtro de conta e busca por expressão regular. O arrasto usa eventos de ponteiro (route-drag.ts), não o drag-and-drop do HTML5. Cada salvamento grava o principal e depois os fallbacks; a janela ignora os recarregamentos intermediários. Oavailable-modelsdevolvekey_labels(as contas que listam o modelo, por prioridade); escolher um modelo de um provedor com várias contas fixa a rota naquela conta. - Demonstração da topologia:
scripts/dev/topology-demo.shcria, num banco local de dev (padrão: contêinerthinker-dev-prodcopy), o workspace "Demo Topologia" com todas as combinações (clientes IPv4, IPv6 e IPv4 mapeado; harnesses com e sem tráfego ou rotas; modelos virtuais com fallback; rotas auto; contas por chave, assinatura, provedor custom e servidores locais; perfis só com proxies, com proxies e Direct, só Direct e vazio; atribuição por conta, herdada do padrão do provedor e sobrescrita para direto) e gera requisições, tentativas, logs e estatísticas de proxy relativos ao horário atual.--livecontinua gerando tráfego a cada 10 s;--removeapaga o workspace. O backend guarda em cache os workspaces de cada usuário, então reinicie-o depois de criar o workspace para ele aparecer.
O dashboard fala inglês (en, referência e fallback) e português do Brasil (pt-BR), com um t() próprio e tipado (packages/frontend/src/i18n/index.ts). Os catálogos vão no bundle: nada é baixado em tempo de execução.
- Onde ficam:
packages/frontend/src/i18n/locales/{en,pt-BR}/<namespace>.ts, um arquivo por área (common,nav,auth,org,routing,providers...). Opt-BRé tipado comoCatalog<typeof en>, então o typecheck acusa chave faltando. - No código:
t('routing.title'), com{{ nome }}preenchido pelo segundo argumento (t('format.minutesAgo', { n: 5 })). Plurais são funções no catálogo, com a mesma assinatura nos dois idiomas. Chaves montadas em tempo de execução usamtMaybe, que devolveundefinedquando a chave não existe. A árvore do app é remontada quando o idioma muda, então basta chamart()na renderização; constantes de módulo guardam chaves, não textos. Plurais recebem os argumentos na ordem do catálogo (t('shell.alerts.labelUnseen', 3)). - Datas, números e moeda: sempre por
IntlcomintlLocale()ou pelos helpers deservices/formatters.ts(formatCost,usd,formatNumber,formatTime). Em inglês a saída continuaen-US. - Idioma escolhido: em Preferências da conta (
/account). O padrão vem do navegador (pt*→pt-BR, senãoen). A escolha fica nolocalStorage(thinker.locale.<userId>, maisthinker.localepara as telas de login) e volta depois de um novo login. Ainda não há preferência no backend, então ela vale por navegador. - Fica em inglês: nomes de marca, provedor e modelo, comandos e trechos de código, variáveis de ambiente, URLs e mensagens de erro vindas da API.
Fluxo para mudar ou criar um texto:
- Adicione a chave no arquivo
enda área e a tradução nopt-BRcorrespondente. Use o glossário do PT-BR já adotado: harness, provedor, requisição, tentativa, token, chave de API, fallback, roteamento, nível, modelo virtual, organização, papel. - Rode
npm run check:i18n --workspace=thinker-frontend(também roda nonpm run lintdo frontend e no CI). Ele falha quando uma chave existe emene falta empt-BR(ou o contrário, ou os{{ }}diferem) e quando um componente mostra texto fora do catálogo: texto JSX,placeholder/title/aria-label/alt/label, literais no JSX e toasts. Um literal que precisa ficar como está leva um comentárioi18n-ignorena mesma linha ou na anterior. - Os testes rodam em inglês por padrão (
tests/setup.ts). Para conferir uma tela em português, chameresetLocaleForTests('pt-BR')(exemplos empackages/frontend/tests/i18n/).
As colunas de data são timestamp without time zone e recebem valores do Node (o driver pg serializa um Date no fuso do processo), do PostgreSQL (NOW()/CURRENT_TIMESTAMP, no fuso da sessão) e de toSqlTimestamp (UTC). Para que concordem, o Thinker fixa os dois relógios em UTC (common/utils/utc-clock.ts):
- processo:
import './common/utils/pin-utc'é o primeiro import demain.ts; os CLIs de migração e backfill fixam o fuso emdatabase/load-env-file.ts. UmTZdo operador é ignorado, com aviso no boot; - sessão:
pinSessionTimeZoneToUtcé oonConnectdos poolspgdo TypeORM (database.module.ts), do CLI de migrações e do Better Auth. TodoDataSourceouPoolnovo precisa do mesmoonConnect.
Alguns utilitários antigos (postgres-sql.ts, query-helpers.ts) ainda formatam datas com os getters locais (getHours…) e falam em "fuso local" nos comentários. Estão corretos só porque o fuso local é UTC pelo pin: um processo novo sem pin-utc quebra os cortes e buckets deles.
Não há migração de dados: o container padrão já roda em UTC.
O esquema é gerenciado só por migrações TypeORM em packages/backend/src/database/migrations/ (synchronize é sempre false). Elas rodam no boot, controladas por RUN_MIGRATIONS_ON_BOOT (padrão true; false nas réplicas que não devem migrar).
cd packages/backend
npm run migration:generate -- src/database/migrations/NomeDescritivo
npm run migration:run
npm run migration:revert
npm run migration:show
npm run migration:create -- src/database/migrations/NomeRegras:
- Migração antiga é imutável. Para remover algo, crie uma migração nova (como
DropBillingSchema,DropAutofixSchema). - Use um timestamp único; nunca reaproveite o de outra migração.
- Importe a nova migração em
database/data-source-definitions.tse inclua-a no arraymigrations(o specdata-source-definitions.spec.tsfalha se faltar). Entidades novas entram no arrayentitiesdo mesmo arquivo, noTypeOrmModule.forFeaturededatabase.module.tse na lista detest/helpers.ts. - O modo de transação é
each(uma transação por migração), porque algumas migrações de índice usamCONCURRENTLY. - Migrações costumam ter um spec ao lado; mudanças de dados ganham também um e2e.
- As tabelas do Better Auth têm migrações próprias, geridas por ele:
DatabaseSeederServicechamactx.runMigrations()no boot. A checagem de schema do Better Auth na inicialização (advanced.database.validateSchema) fica desligada, porque roda antes dessa migração e registrava um falso "Database schema mismatch" em todo banco novo.auth/better-auth-schema.tsfaz a mesma checagem logo depois da migração e só registra ERROR quando há divergência real.
Quatro guards globais, nesta ordem:
- SessionGuard (
auth/session.guard.ts): respeita@Public(); valida o cookie de sessão do Better Auth. - ApiKeyGuard (
common/guards/api-key.guard.ts): sem sessão, lêX-API-Keye procura na tabelaapi_keys(hash scrypt, escopo de tenant). Só se não achar compara, em tempo constante, com a variávelAPI_KEY. Os tokens da CLI (thinker login) são linhas deapi_keyschamadascli, com TTL deslizante (CLI_TOKEN_TTL_DAYS, 30) e teto absoluto (CLI_TOKEN_ABSOLUTE_TTL_DAYS, 90). - RolesGuard (
common/guards/roles.guard.ts): papel na organização ativa (ADR 0006). Leituras pedemviewer; os demais métodos,member.@MinRole('admin')sobe a exigência das mutações (provedores, credenciais, roteamento, notificações); com{ includeReads: true }vale também para GET (início de OAuth;GET /api/v1/agents/:name/key, que devolve a chave completa do harness, exigemember).@InstanceAdminOnly()restringe ao admin da instância. - ThrottlerGuard: limite de requisições (
THROTTLE_*); o proxy/v1/*e o SSE ficam fora (@SkipThrottle()), pois têm limites próprios (THINKER_RATE_MAX_REQUESTS,THINKER_IP_RATE_MAX_REQUESTS,THINKER_CONCURRENCY_MAX).
As rotas /v1/* usam a chave do harness (AgentKeyAuthGuard, em otlp/guards/), em Authorization: Bearer thk_... ou x-api-key. Chaves diferentes nos dois cabeçalhos dão M006. A verificação consulta o banco a cada requisição para que rotação e exclusão valham em todas as réplicas; o contexto verificado fica em cache de 5 minutos.
Better Auth (auth/auth.instance.ts): e-mail/senha, Google, GitHub e Discord (cada um liga quando CLIENT_ID e CLIENT_SECRET estão definidos) e os plugins jwt() e mcp() (servidor de autorização OAuth 2.1 do MCP). É montado como middleware Express em /api/auth/*splat, antes de express.json(). auth.instance.ts é só ESM e não carrega no Jest; por isso constantes como auth/mcp-scopes.ts e auth/mcp-availability.ts ficam em módulos folha.
Cadastro fechado (#29): ninguém cria conta pela API pública. auth/signup-policy.ts decide e auth.instance.ts impõe em databaseHooks.user.create.before (cobre e-mail, social e plugins), com um hooks.before em /sign-up/email. A recusa é 403 { code: 'SIGNUP_DISABLED' }; provedores sociais têm disableSignUp. O primeiro usuário só nasce no assistente (setup/setup.service.ts, trava advisory 9001), dentro de runAsServerUserCreation, uma marca em AsyncLocalStorage que nenhuma requisição HTTP consegue definir. THINKER_ALLOW_SIGNUP=true é uma válvula provisória (removida na v0.3) que reabre o cadastro depois que o primeiro administrador existe; GET /api/v1/setup/status a expõe como signupEnabled.
Token de setup (#29): sem nenhum usuário no boot, setup/setup-token.service.ts gera um token aleatório, guarda só o SHA-256 na memória e imprime o token no log (No admin account exists yet. Open /setup and enter this one-time setup token: ...). POST /api/v1/setup/admin exige o token em setupToken (403 sem ele ou com valor errado; um token novo é impresso se nenhum está pendente). Ele é consumido ao criar o primeiro administrador. Cada processo tem o seu token, então com várias réplicas o primeiro setup precisa ir à réplica que o imprimiu.
Cifragem em repouso: THINKER_ENCRYPTION_KEY (obrigatória, 32+ caracteres, diferente de BETTER_AUTH_SECRET, sem fallback; assertEncryptionKeyConfig barra o boot) cifra com AES-256-GCM as chaves de provedores, os tokens OAuth e as gravações (common/utils/crypto.util.ts). THINKER_ENCRYPTION_KEY_PREVIOUS serve para rotação: no boot, database/secret-reencryption.service.ts regrava com a chave nova o que estava sob a antiga (com trava advisory) e registra Secret re-encryption complete e, quando nada sobrou, Nothing left under an older secret. A mesma receita migra instalações que usavam o antigo fallback para BETTER_AUTH_SECRET: chave nova em THINKER_ENCRYPTION_KEY, o valor antigo de BETTER_AUTH_SECRET em THINKER_ENCRYPTION_KEY_PREVIOUS, subir, esperar o log e remover a variável. As gravações não são regravadas.
Chaves de provedor na API: nenhuma listagem ou detalhe devolve a chave inteira. common/utils/provider-key-mask.ts (providerKeyDisplay) decifra no servidor as chaves api_key e local e devolve key_masked (no máximo metade visível: budget = floor(len/2), prefixo min(12, budget), sufixo min(4, budget - prefixo), meio em * com no máximo 8; public vira pub***) e key_prefix passa a ser só a parte inicial visível dessa máscara. Credenciais de assinatura não são decifradas, e chave que não decifra cai no key_prefix gravado (8 caracteres), cortado pela mesma regra. Valem GET /api/v1/providers, GET /routing/:agent/providers, custom providers, o detalhe da conexão e a ferramenta MCP. A chave inteira só sai por POST /api/v1/providers/connections/:id/reveal-key (@MinRole('owner') e de novo OrganizationsService.require(..., 'owner'), escopo por tenant_id, Cache-Control: no-store), que recusa assinatura/OAuth (400 credential_not_revealable) e chave que não decifra (409 key_not_decryptable) e registra cada revelação no log (Provider key revealed: user=… tenant=… connection=…, nunca a chave). No dashboard, o campo Credential do registro da conta (components/ConnectionKeyField.tsx) mostra a máscara e, só para owner em conexões api_key/local, o botão de olho e o de copiar.
IP do cliente (skill docker-reverse-proxy-real-ip-address): common/utils/client-ip.ts carrega no boot o conjunto de proxies confiáveis (faixas privadas e reservadas, ou só os prefixos de TRUSTED_PROXIES; uma entrada inválida barra o boot) e resolve o endereço de cada requisição: se o peer TCP não é confiável, vale ele e os cabeçalhos são ignorados; se é, X-Forwarded-For percorrido da direita para a esquerda (até 2048 caracteres e 30 saltos; uma entrada inválida descarta o XFF), depois X-Real-IP, por fim o peer, com aviso limitado a um por minuto. clientIpMiddleware é o primeiro middleware do app: fixa req.ip no resultado (throttler, limites por IP e guards concordam) e grava x-thinker-client-ip, x-thinker-client-ip-source e x-thinker-peer-ip (sobrescrevendo o que o cliente mandou), que o Better Auth usa em advanced.ipAddress.ipAddressHeaders e nas colunas extras de sessão ipAddressSource e peerIpAddress. O trust proxy do Express usa o mesmo conjunto (para X-Forwarded-Proto). Os logs trazem client_ip, client_ip_source e peer_ip (clientIpLogFields), e o proxy grava os três em caller_attribution, junto de peerPort (porta TCP de origem da conexão que chegou ao contêiner). A listagem de Requisições extrai clientIp numa coluna própria. Os "Headers de requisição" (request_headers, routing/proxy/request-headers.ts) guardam todos os cabeçalhos exatamente como chegaram (montados de req.rawHeaders, então incluem X-Forwarded-For, Host, Forwarded etc. e não incluem os x-thinker-* gravados pelo middleware); credenciais (authorization, cookie, x-api-key…) aparecem com o valor trocado por [REDACTED], preservando só o esquema de autenticação. O detalhe da requisição (GET /api/v1/messages/:id/details) só devolve esses cabeçalhos, da requisição e de cada tentativa, a owner e admin da organização (e ao admin da instância); para member e viewer o backend manda request_headers: null e a aba não aparece. TRUST_PROXY não é mais lido; o boot avisa se estiver definida.
Linha do tempo da requisição (routing/proxy/request-timeline.ts): clientIpMiddleware marca o primeiro contato HTTP (common/utils/request-received.ts); o ProviderClient marca, por tentativa, o envio ao provedor (logo antes do fetch), a chegada dos cabeçalhos e o primeiro e o último chunk do corpo (o corpo passa por um TransformStream que conta chunks e bytes); o controller marca o início (writeHead) e a conclusão (finish) da resposta ao harness. No close da conexão tudo vira requests.timeline (jsonb, eventos com at em epoch ms, migração AddRequestTimeline1804200000000). O painel de detalhes (components/RequestDrawer.tsx) mostra na guia Detalhes as seções Origem (IP oficial, fonte, IP:porta da conexão), Linha do tempo (components/RequestTimelineChart.tsx) e Headers da requisição. As demais guias ficam em components/RequestAttemptViews.tsx: Requisição (parâmetros e conversa), Resposta (partes em ordem: pensamento, texto, recusa, chamadas de ferramenta, e os eventos do stream, lidas por lib/response-parts.ts para OpenAI, Responses, Anthropic e Gemini), Ferramentas (assinatura function nome(tipo arg, tipo opcional?), obrigatórios em laranja, e quais foram chamadas) e Bruto (components/JsonExplorer.tsx: árvore navegável com busca e cópia de caminho/valor). O proxy grava a metade da requisição assim que ela é montada e a gravação completa no fim (a final espera a parcial), e o painel consulta de novo a cada 1,5 s enquanto a requisição está pendente; os detalhes nunca passam pelo cache SWR. O painel pode virar janela flutuante (arrastável, redimensionável, minimizável) ou abrir em nova guia na rota /requests/:id (pages/RequestPage.tsx, tela inteira). O gráfico da linha do tempo: gráfico por raia (Thinker e cada tentativa) com a duração de ponta a ponta e uma tabela com timestamp em milissegundos, tempo desde o início e intervalo entre eventos.
Nomes herdados: common/utils/legacy-env.ts registra no boot um aviso com o nome (nunca o valor) de cada variável MANIFEST_* ainda definida e recusa iniciar se MANIFEST_ENCRYPTION_KEY estiver definida sem THINKER_ENCRYPTION_KEY.
Usuário (Better Auth) ──→ organization_members ──→ Tenant (organização) ──→ Agent ──→ AgentApiKey (thk_*)
└──→ requests ──→ agent_messages
- Organização = tenant (
tenants, ADR 0006). O nome visível éorganization_name.owner_user_idé legado e não é mais lido na resolução. - Membros (
organization_members):(tenant_id, user_id, role), papéisviewer < member < admin < owner. Um usuário pode estar em várias organizações. - Perfil (
user_profiles):is_instance_admin,must_change_passwordeactive_tenant_id. - Resolução (
TenantCacheService.resolveAccess, cache de 5 min invalidado em toda mudança de membro ou perfil): cabeçalhoX-Thinker-Organization, senãoactive_tenant_id, senão a primeira associação. Organização pedida sem associação dá 403, exceto para o admin da instância, que entra comoowner. Sem organização nenhuma, a primeira mutação cria uma pessoal (ensureForUser); remover um membro zera oowner_user_idque ele tinha na organização, eensureForUsernunca reaproveita uma organização que tenha outros membros, então quem foi removido não volta como owner. - Os guards anexam um
TenantContext(@TenantCtx()) comtenantId,userId,role,isInstanceAdminemustChangePassword. Toda consulta filtra por tenant (addTenantFilter). O filtro por harness (agent_name) do log de requisições é resolvido uma vez por requisição para o id do agente vivo (resolveAgentScope, emquery-helpers.ts) e reutilizado em todas as consultas; os caches de resultado usam o tenant mais o id resolvido (agentScopeCacheKey), nunca só o nome. Nunca escope, cacheie ou autorize por id de usuário. Nunca escope, cacheie ou autorize por id de usuário. - Chaves de gestão (
api_keys, chavesthk_adm_e tokensthk_pat_da CLI) representam quem as criou (ADR 0006 item 4): nas rotas escopadas valem na organização da chave com o papel atual dele ali; em/api/v1/organizations/:id/*e/api/v1/admin/*valem com o papel dele em cada organização e com o seuisInstanceAdmin(usado porthinker orgethinker user). Uma chave vazada equivale à conta inteira. O MCP limita as ferramentas de escrita pelo papel (canWriteemmcp/mcp-auth.ts: agentes pedemmember; provedores e roteamento,admin). - Contas (
organizations/): o admin da instância e owner/admin de uma organização criam contas com senha temporária (AccountsService, viarunAsServerUserCreation). Enquantomust_change_passwordestiver ligado, oSessionGuardsó deixa passarGET /api/v1/meePOST /api/v1/me/password(403password_change_required), e nenhuma chave de gestão da conta autentica (ApiKeyAuthServicedevolve o mesmo 403; no MCP a chave é recusada). A redefinição pelo admin apaga os tokensthk_pat_dothinker loginda conta (as chavesthk_adm_continuam, bloqueadas até a troca) e as linhas desession, mas o cookie cache do Better Auth (5 min) mantém o cookie antigo válido nesse intervalo; como a flag volta a ligar e a troca exige a senha atual, esse cookie não faz nada além de ler/me. - Agent (
agents): pertence a um tenant; único por[tenant_id, name]. - AgentApiKey (
agent_api_keys): a chavethk_*do agente. Só autentica se a organização estiver ativa: apagar (desativar) a organização corta o proxy dos seus harnesses na hora, inclusive para chaves no cache doAgentKeyAuthGuard. ApiKey (api_keys): credencial de gestão do tenant, distinta da anterior.
O proxy (routing/proxy/) expõe POST /v1/chat/completions, POST /v1/responses, POST /v1/messages e GET /v1/models (e GET /v1/models/{id}; no formato Anthropic quando o cliente manda anthropic-version). Todo modelo real de /v1/models traz context_length quando a janela é conhecida: nunca o padrão de 1M que a descoberta usa quando ninguém informa (contextWindowSource: 'default'), e openrouter/free publica o menor :free (ADR 0004, seção 7, emenda). GET /version (público, fora de /api, health/version.controller.ts) responde {"version":"thinker-X.Y.Z","name":"thinker"} a partir de packages/thinker/package.json (copiado para a imagem), para harnesses que classificam o servidor (Hermes); de propósito não há /props. Rotas de passthrough (routing/proxy/passthrough.ts), sem roteamento por complexidade, fallback nem modelos virtuais: POST /v1/embeddings e POST /v1/audio/transcriptions (multipart) vão ao provedor que o model nomeia (provedor/modelo ou inferido do id), com a credencial do agente para ele; POST /v1/messages/count_tokens vai à Anthropic quando o modelo é um Claude alcançável e, nos demais casos (inclusive auto), devolve a estimativa de estimateRequestTokens. As três saem pelo perfil de proxy de saída da conexão (o egressScope vem junto da credencial em resolvePassthroughCredentials), são abortadas quando o cliente desconecta ou após PASSTHROUGH_TIMEOUT_MS (300 s), e embeddings e transcrições passam pelos limites M201/M202/M203 do chat; ainda não geram linha de custo nem de tokens. A precedência fica em ProxyService.resolveRouting (ADR 0004, seção 5):
- Modelo virtual (
ResolveService.resolveVirtualModel): um virtual ativo do agente cujo nome é igual aomodel, sem diferenciar maiúsculas e sem outra normalização. auto→ um heartbeat (autocomHEARTBEAT_OK) vai direto ao tiersimple; os demais vão aresolveLazy: complexidade, seagents.complexity_routing_enabled(padrãofalse), comscoring/classificando emsimple,standard,complexoureasoning; com a opção desligada, tudo vai ao tierdefault. ID real → rota explícita (resolveExplicitModel). Desconhecido →M302.
Os tiers por cabeçalho (regras x-thinker-tier, tabela header_tiers, API /header-tiers) foram descontinuados (ADR 0008); a migração DropHeaderTiers1804100000000 apaga a tabela, as colunas agent_messages.header_tier_* e os parâmetros com escopo header:<id>.
Um tier pontuado sem modelo cai no tier default; se o default também não tem rota, é M101. Um tier com modelo configurado porém indisponível não cai no default (aparece M302/M101).
Modelos virtuais (ADR 0004, routing/virtual-models/): nomes públicos por harness (tabela virtual_models), cada um com principal, até 5 fallbacks, continue_on_default, response_mode e output_modality. A API administrativa fica em /api/v1/routing/:agentName/virtual-models (CRUD, toggle, reorder, override, fallbacks, response-mode); o GET /v1/models publica os ativos logo depois de auto, com context_length. Regras:
- No proxy,
resolveVirtualModelmonta a cadeia combuildVirtualModelChain(enrichRouteKeyLabelcomonormalize, antes da deduplicação por(provider, authType, model, keyLabel);isRouteAvailabledecide se o override do Default está disponível). As rotas do virtual passam porisRouteAvailable; a parte do Default seguebuildResolvedRouteChain(override disponível ou primeira rota com credencial na frente, as demais comisRouteAvailable). Depois vem o filtro de transporte com oresponse_modedo virtual. Não passa pelo scorer, pela especificidade nem pelo momentum. Sem rota,M301, sem queda para outra regra. - Observabilidade:
X-Thinker-Reason: virtual-model,X-Thinker-Virtual-Model: <nome>,X-Thinker-Tier: defaulte osX-Thinker-Fallback-*de sempre (a lista completa dos cabeçalhos, embuildMetaHeaders, está em Provedores e roteamento). Toda linha deagent_messagesdo pedido (pendente, tentativas, sucesso, erros,M301) gravavirtual_model_idevirtual_model_name; orouting_reasonévirtual-model(ouvirtual_model_unavailablenoM301). - Parâmetros por modelo são escopados por rota:
ParamMergeContext.scopeFor(route)respondevirtual:<id>para as rotas do virtual etier:defaultpara as que vieram do Default (route_params_scopesnoResolveResponse). O mesmo vale para o snapshotrequest_params, que cada linha deagent_messagesguarda por tentativa, sob o escopo da própria rota: o principal (RoutingMeta.primaryRequestParams) e cada fallback que falhou (FailedFallback.requestParams), inclusive nos fallbacks comuns de tiers, em streaming e em cadeia esgotada. A linha do principal usaprimaryRequestParams, cada hop que falhou usaFailedFallback.requestParams, e só a linha do vencedor usa a re-derivação. Um pedido para um virtual não éexplicit_model_override. - O nome segue
validateVirtualModelNamedethinker-shared(1 a 64 caracteres,autoreservado) e é único por agente sem diferenciar maiúsculas: o serviço devolve 409, e o índice único(agent_id, LOWER(name))fica só na migração. - A validação das rotas na escrita é estrita: uma rota que não resolve no catálogo do agente (
getModelsForAgent, já filtrado pelos provedores habilitados) devolve 400, e umkeyLabelenviado que não existe para o provedor também. SemkeyLabel, a rota é guardada sem rótulo; a chave padrão é escolhida na hora do pedido. - A listagem calcula
shadows(IDs reais que o nome sombreia) econtext_lengthcom um único snapshot do catálogo por requisição, semisRouteAvailablerota a rota (virtual-model-chain.ts). - A lista do agente fica em cache no
RoutingCacheService(getVirtualModels) e cai junto cominvalidateAgent, o que cobre as escritas no tierdefault. - Apagar um virtual apaga as linhas de
agent_model_paramscomscope_key = 'virtual:<id>'(modelParamsScopeForVirtualModel). - CLI e MCP usam o mesmo
VirtualModelService:thinker routing virtual list|create|update|delete|enable|disablee as ferramentasthinker_routing_virtual_*(mcp/tools/virtual-models.tools.ts). As duas acham o virtual pelo nome sem diferenciar maiúsculas, ou peloid.routing status/thinker_routing_statuslistam os virtuais e acrescentamroutes_by_virtual_modeleactive_virtual_models, a partir do camporoutableda listagem (virtualModelRoutable: a mesma cadeia que o proxy monta, só com o slotdefaultemcontinue_on_default, filtrada pelo snapshot do catálogo) (oRoutingStatuscompartilhado com o dashboard continua falando só deauto; no MCP,status_for: "auto"e, quando só os virtuais roteiam,reason: "auto_not_routable"com a causa original emauto_reason), erouting test/thinker_routing_testaceitam o nome emmodele devolvemrouting_reasonevirtual_modellidos dos cabeçalhosX-Thinker-*. - Parâmetros por nome:
RouteModelParamsServiceaceita como alvo um tier (string) ou{ virtual: <nome> }. São seletores separados, porque um tier e um virtual podem ter o mesmo nome (hoje o único tier endereçável édefault). O REST éGET|PATCH /api/v1/routing/:agentName/virtual-model-params?virtual=<nome>&model=<id>, ao lado detiers/:tier/model-params. O MCP usa o campovirtual, exclusivo comtier, e a CLI usa--virtual, exclusivo com--tier. Só a cadeia do próprio virtual é endereçável: as rotas emprestadas do Default usam os parâmetros do tierdefault. O endpoint por escopo (PUT|DELETE /model-paramscomscope: "virtual:<id>") grava as mesmas linhas. Omodelcasa em camadas, e a primeira com resultado vence: (1) o ID guardado, (2) o ID público da rota (openAiModelIddo catálogo do agente, por exemplo<alias>/<modelo>de um custom provider), (3) o ID nativo, com ou sem o prefixo do provedor. Só há ambiguidade dentro da camada vencedora, e a mensagem sugere apenas nomes que selecionam uma única conexão; a resposta trazpublic_modelepublic_models.
Fallback: cada tier tem um modelo principal e até 5 de reserva (routing/dto/routing.dto.ts). Qualquer status HTTP >= 400 (fallback-status-codes.ts), timeout de tentativa (PROVIDER_TIMEOUT_MS), falta de primeiro trecho (streamWarmupMsFor em stream-warmup.ts: tenant_providers.stream_warmup_ms da conexão selecionada, que vem no cache de chaves sem consulta extra > THINKER_LOCAL_STREAM_WARMUP_MS para runners locais > STREAM_WARMUP_MS) ou silêncio em streaming (STREAM_IDLE_TIMEOUT_MS) leva à próxima rota (proxy-fallback.service.ts). Se todas falham, o cliente recebe o erro do provedor da última tentativa (não um código M###). Antes da próxima rota, um provedor custom com várias chaves (custom:<uuid> com mais de uma linha ativa em tenant_providers) tenta as outras chaves em ordem de prioridade quando a resposta é 401, 403 ou 429 (tryForwardToProvider); chaves em cooldown de 429 ou rejeitadas por 401 são puladas, a tentativa substituída é fechada como superseded e ForwardResult.servedByKey informa a conexão que atendeu, para o carimbo de tenant_provider_id e do rótulo. Erros disfarçados (relatório da NOC Norte, upstream-error-classifier.ts): no funil forwardWithKeyInScope, um 200 sem streaming cujo JSON só traz error (nenhum de choices, content, candidates, output, data, completion; até 2 MiB) vira o status de error.code (400-599; senão 400 se for erro de contexto e 502 nos demais), com o cabeçalho x-thinker-embedded-upstream-error: 1 (THINKER_EMBEDDED_ERROR_CHECK=false desliga). O warm-up do streaming também roda no funil (peekStreamStart, ForwardProviderOptions.warmupMs): depois do primeiro byte, só para text/event-stream, os comentários SSE, keepalives (: OPENROUTER PROCESSING), deltas vazios e eventos de abertura (message_start, ping, response.created/in_progress) ficam retidos até o primeiro conteúdo (replay byte a byte), um evento de erro (data com error, event: error, response.failed; vira o status do erro) ou o limite (THINKER_STREAM_PEEK_MS, 20 s; THINKER_STREAM_PEEK_MAX_BYTES, 256 KiB). THINKER_STREAM_ERROR_PEEK = all (padrão), context (só erro de contexto) ou off. Um erro depois do primeiro conteúdo continua chegando ao cliente como SSE. Nos dois casos um 429 entra no cooldown. Nova tentativa na mesma rota (upstream-retry.ts, tryForwardToProvider): THINKER_UPSTREAM_RETRY_MODE = last-route (padrão: só quando a cadeia não tem outra rota, ForwardProviderOptions.hasNextRoute), always ou off; THINKER_UPSTREAM_RETRIES (0-5, padrão 1), THINKER_UPSTREAM_RETRY_DELAY_MS (0-60000, padrão 1000, interrompido se o cliente desconecta), THINKER_UPSTREAM_RETRY_STATUSES (padrão 500,502,503; 504 fica fora). Erro de contexto (isContextLengthBody, só campos estruturados message/code/type/metadata.raw, também usado pelo proxy-error-sanitizer) nunca repete na mesma rota. A tentativa anterior fecha como superseded. Quem serviu (served-provider.ts): o funil lê provider, id e model do corpo buffered, dos primeiros 64 KiB do stream (eventos inteiros, então um campo dividido entre chunks é lido) e de error.metadata.provider_name em erro, e guarda em ProviderAttemptRef.served; o gravador grava agent_messages.served_provider, upstream_generation_id e served_model (migração AddServedProviderAndStreamIdle1805500000000). Os bytes ao cliente não mudam. THINKER_SERVED_PROVIDER_CAPTURE=false desliga. O detalhe da mensagem mostra "Served by" e a lista de mensagens filtra por served_provider (sem diferenciar maiúsculas). Idle do streaming por conexão: tenant_providers.stream_idle_timeout_ms (1000 a 3600000; PATCH /api/v1/providers/connections/:id/stream-idle, stream_idle_timeout_ms no PUT de custom provider) vira ForwardResult.streamIdleTimeoutMs e o idleTimeoutMs do relay; sem valor, um servidor local usa o próprio request_timeout_ms e o resto usa STREAM_IDLE_TIMEOUT_MS.
Preços curados por provedor (model-prices/provider-price-seeds.ts): preços que os catálogos públicos não têm ou erram (faixa de contexto longo, mudança de preço em data conhecida, cache), sempre escopados ao vendedor. O ModelPricingCacheService os sobrepõe ao models.dev no mapa por provedor (custo gravado) e a descoberta os usa no preço exibido. computeTokenCost aplica long_context ao pedido inteiro quando o prompt passa do limite, time_tiers com from/until (sem janelas = o dia todo) e cobra a escrita de cache de 1 h da Anthropic (cache_creation_1h_tokens) a 2x a entrada.
Política de parâmetros por provedor (routing/proxy/param-policy.ts): depois de montar o corpo de cada tentativa, ProviderClient aplica uma tabela única de regras por provedor e família de modelo (remover, renomear ou fixar campos que o destino recusa, e traduzir o esforço de raciocínio para o formato dele: output_config.effort no Claude 4.6+, thinkingConfig no Gemini, reasoning no OpenRouter, níveis do Kimi K3 e do GLM 5.3). Cada regra cita a lacuna do dossiê em docs/externos/ de onde veio; regra nova entra nessa tabela, não nos adaptadores. A saída é normalizada em reasoning-format.ts (reasoning da Cerebras e chunks thinking do Mistral viram reasoning_content).
Roteamento do OpenRouter por rota (relatório da NOC Norte, P8): toda rota OpenRouter com chave de API ganha os parâmetros provider.* (order, only, ignore, quantizations em string_list; allow_fallbacks, zdr, require_parameters, enforce_distillable_text; sort, data_collection; preferred_min_throughput, preferred_max_latency, max_price.prompt/.completion), acrescentados pelo ProviderParamSpecService.getSpecs (routing-core/openrouter-provider-routing.ts) e guardados como os demais parâmetros por rota. Eles têm clientOwnsRoot: se o pedido já traz um objeto provider, nenhum provider.* da rota entra (applyRequestParamDefaults). Os fallbacks não herdam, porque os parâmetros são por rota. O detalhe da mensagem ("Served by") confirma se o pin valeu. O diálogo de parâmetros mostra a seção "Provider routing" (campos básicos e JSON avançado validado) e avisa que order manual desliga a afinidade de sessão do OpenRouter. As linhas de rota cujo modelo saiu da lista da conexão mostram o badge "Not in catalog" (só quando a lista está carregada e não vazia).
Roteamento ciente de context window (#134, routing/proxy/context-window-fit.ts): antes da primeira tentativa, ProxyService.fitChainToContext estima os tokens do pedido (texto de messages/system/input/instructions, sem imagens e arquivos inline, mais as tools serializadas, a ~4 caracteres por token, somando max_completion_tokens/max_tokens/max_output_tokens) e tira da cadeia (principal e fallbacks) toda rota cuja janela conhecida no catálogo do agente (DiscoveredModel.contextWindow, lida com snapshotRouteState, a mesma regra do context_length de /v1/models) seja menor que a estimativa com folga: pula quando estimativa > 0,95 × janela. Janela desconhecida nunca pula; se nenhuma rota couber, a cadeia fica como estava. Pedidos com estimativa até 0,95 × 4096 nem consultam o catálogo. As rotas puladas saem em X-Thinker-Context-Skipped (IDs separados por vírgula) e o routing_reason não muda. Custom providers aceitam context_window por modelo (coluna "Context window" do formulário, guardado no JSON models). O piso context_length do auto em /v1/models continua sendo o menor da cadeia: é o tamanho que cabe em qualquer rota.
SSRF: URLs informadas pelo usuário (provedores customizados, URLs de assinatura) passam por validatePublicUrl e por common/utils/ssrf-safe-fetch.ts (DNS fixado, endereços de metadata de nuvem sempre recusados). LAN, loopback e http:// são permitidos (recurso, não falha), salvo quando quem salvou a URL não podia usar destino privado pela THINKER_PROVIDER_PRIVATE_URLS: custom_providers.allow_private_destination e tenant_providers.allow_private_destination guardam o que a política permitiu a quem salvou (linhas antigas ficam true), e com false o lookup fixado também recusa IP privado, de LAN e loopback na conexão (ssrfSafeFetch(url, init, { allowPrivate: false })), então trocar depois o DNS de um nome público para a rede interna não adianta. Com a política allow, toda URL salva pode usar destino privado. Uma URL recusada falha a tentativa com HTTP 502 e error.code: "provider_url_blocked", e o fallback continua. HTTP_PROXY/HTTPS_PROXY do processo são ignorados; o proxy de saída é o dos Servidores Proxy abaixo. Através de um proxy quem resolve o DNS do destino é o proxy: o Thinker ainda recusa nomes e IPs literais de metadata antes de enviar, e fixa o lookup do host do proxy (ssrfSafeLookup), mas o risco de rebinding do destino passa a depender do proxy escolhido pelo workspace.
URL base por conta (tenant_providers.base_url, migração AddTenantProviderBaseUrl1805400000000): só contas api_key de provedores que aceitam troca de endpoint (supportsAccountBaseUrl em routing/account-base-url.ts). A URL é normalizada (normalizeAccountBaseUrl), validada por checkAccountBaseUrl (validatePublicUrl; destinos privados só com canUsePrivateDestinations) e vence a região e o endpoint padrão: ForwardEndpointResolver monta o override antes de região e custom (Bedrock via resolveBedrockEndpointKey) e a descoberta de modelos reescreve as URLs com rebaseUrl. Vem no cache de chaves (CachedProviderKey.baseUrl, providerBaseUrl em resolveRouteCredentials, Playground e fallback). API: PATCH /api/v1/providers/connections/:id/base-url ({base_url: string | null}, redescobre os modelos), GET /api/v1/providers/default-base-url?provider= e accountBaseUrl no connect; as listagens trazem base_url, default_base_url e supports_base_url (baseUrlFields). No dashboard, o campo fica no wizard de nova conta (components/AccountBaseUrlField.tsx) e no diálogo Manage da conexão.
Wizard de nova conta (components/ProviderWizard.tsx): passo 1 escolhe a variante do provedor (providerVariants: OpenCode Zen/Free em Por uso e Console/Go em Assinaturas, MiniMax login/token, Vertex express/projeto), passo 2 mostra o formulário (ProviderKeyForm/DeviceCodeDetailView) com a região travada e a URL base da variante.
Provedores → Modelos (/providers/models, pages/providers/ProviderModels.tsx): GET /api/v1/providers/models[?include_inactive=true] (buildProviderModels em routing/provider-models.ts) junta os cached_models de cada conexão e os modelos dos custom providers numa entrada por (provedor, id), com as contas que servem cada modelo e o preço de cada uma. A página tem as visões por modelo e por conta, filtros de provedor, tipo de conta e capacidade, e o detalhe numa janela flutuante.
Servidores Proxy (proxy HTTP de saída): especificação em tmp/PROXY-SPEC.md. Todo fetch de saída passa por outboundFetch (common/outbound/outbound-proxy.ts); ssrfSafeFetch delega a ele com pinDestination. O escopo vem de um AsyncLocalStorage (OutboundScope) aberto por quem conhece o objeto vinculado: ProxyEgressService.scopeForConnection (em resolveRouteCredentials, inclusive a renovação OAuth, em ProxyFallbackService.forwardWithKey para cada chave, no Playground, na descoberta de modelos e no probe de custom provider existente), o interceptor OutboundProviderScope('<provedor>') nos controllers de OAuth (troca de código, device flow, revogação) e scopeForTarget('trigger', ...) no TriggerWorkerService (webhook, Telegram e e-mail; Syslog não é HTTP). Regras:
- Perfil de saída (
proxy_profiles, migraçõesAddProxyProfiles1804800000000eAddProxyProfileLines1805700000000): lista nomeada de até 6 linhas (proxy_profile_lines:line,modeproxy|direct|affinity|request,on_failurenext_member|next_line,no_sessionrequest|agent|first). Os itens ficam emproxy_bindingscomtarget_type 'profile'(line,position= ordem total no perfil,proxy_idnulo = "Direct"). Regras (proxy-servers/profile-lines.ts): a 1ª linha não é Direct, Direct no máximo uma vez, um proxy aparece uma vez no perfil inteiro, linha balanceada com um proxy viraproxy. A API aceitalinesou o atalho legadochain(um item por linha) e devolvelinesechainachatada comlineemode. CLI:--hop <id>|direct|affinity:<id>,<id>|request:<id>,<id>[?on_failure=…&no_session=…]. - Balanceamento de saída (
common/outbound/egress-balance.ts): oProxyEgressServicetransforma a linha balanceada num itemgroup, que orunChainexpande na hora. Afinidade de sessão: hash rendezvous da sessão raiz (parent_session_id ?? session_id, a mesma da página Sessões, posta no contexto peloEgressAffinityInterceptordo controller/v1), estável entre réplicas e restarts; sem sessão valeno_session(por requisição, chave do agente ou primeiro proxy). Por requisição: round-robin por linha (por processo), e "tentar outro" sorteia entre os restantes. Erro de proxy deixa o proxy 30 s em quarentena (por processo; 429 do provedor não conta): linhas balanceadas o pulam e a sessão volta a ele depois; linha toda em quarentena é pulada (skipped_quarantine), salvo se for a última. Os hops levamposition= linha,modeeattempt. - Atribuição (
proxy_assignments:target_typeprovider|connection|trigger|email_config,modeglobal|direct|profile,profile_id). O padrão de um provedor é separado por tipo de conta:target_idlocal:<tipo>(tipo de servidor local:ollama,lmstudiooullamacpp; todo servidor local herda o padrão do seu tipo e também pode usar proxy),api_key:<provedor>(por uso) ousubscription:<provedor>. Sem linha = herdar. Caminho efetivo de uma conexão: atribuição da conexão > padrão do provedor para oauth_typedela >THINKER_HTTP_PROXY(exceto hosts deTHINKER_NO_PROXY) > direto. Os fluxos OAuth usam o padrãosubscription:.direct("Direto - Sem proxy") ignora o proxy global;global("Padrão global") usaTHINKER_HTTP_PROXYou sai direto se ela não estiver definida. A migração transformou cada cadeia antiga em um perfil "Migrated profile N" atribuído ao mesmo objeto (a cadeia de provedor virou os dois padrões). - Transporte: pacote
undicicomProxyAgent(CONNECT para todo destino,proxyTunnel: true), credenciais emProxy-Authorization: Basic,connect_timeout_msno connect e no CONNECT, agentes em cache LRU (256) por id +updated_at. O pool do proxy (TunnelPool) marca erros anteriores ao túnel como erro de proxy e medetunnel_ms. - Failover para o próximo item: conexão recusada/timeout/DNS do proxy, TLS com o proxy, 407, CONNECT não-2xx e 429 do provedor (antes de qualquer byte ao cliente; se o último item também der 429, a resposta segue o fluxo normal de rotação). Demais respostas não trocam de proxy. Proxy desativado é pulado (
skipped_disabled, sem log). Sem item que funcione:ProviderProxyFailedError→ HTTP 502error.code: "provider_proxy_failed"(marcador de gateway como oprovider_url_blocked) e o roteamento segue para a próxima rota. Falha do proxy global também fecha (nunca sai direto). - Registro: cada tentativa grava
agent_messages.egress({kind: proxy|direct|global, proxy_id, name}) eegress_hops([{proxy_id, name, position, result, ms, tunnel_ms, status, error}]), expostos no detalhe da requisição;trigger_event_logstem as mesmas colunas. Cada uso de proxy do workspace incrementaproxy_usage_daily(por dia, proxy,source_type,source_id: conexões, sucessos, falhas por código, soma/máximo do túnel, bytes, usos como fallback) e gravaproxy_logs(só falhas; tudo com debug ligado, que expira emdebug_untilpelo cronproxy-debug-expiry; o botão Testar sempre registra). - API (
proxy-servers/,@MinRole('admin', { includeReads: true })):GET|POST /api/v1/proxy-servers,GET|PATCH|DELETE /proxy-servers/:id(em perfis,DELETEdevolve 409 com a lista;?mode=removetira o proxy de todos os perfis (um perfil sem proxy passa a usar o proxy global), e?mode=replace&replace_with=<id>põe outro no lugar; o proxy sai na mesma transação),POST /proxy-servers/:id/test({url}, GET por um túnel novo, mesmo desativado),GET /proxy-servers/:id/logs,GET /proxy-servers/:id/stats,GET|POST /api/v1/proxy-profilesePATCH|DELETE /proxy-profiles/:id({name, chain: [id | "direct"]}; perfil em uso devolve 409 e?mode=removeapaga as atribuições, que voltam a herdar),GET /api/v1/proxy-assignments[?target_type=]eGET|PUT /proxy-assignments/:targetType/:targetId({mode: inherit | global | direct | profile, profile_id}). A senha é cifrada (password_encrypted, coberta pela re-cifragem) e nunca volta na API. O host do proxy aceita LAN, loopback e rede Docker, e recusa só metadata de nuvem (ao salvar e no connect). - Dashboard: menu do usuário "Proxy configuration" (
/proxy-servers) com as guias Proxy servers (CRUD, logs e estatísticas por servidor), Outbound profiles (components/proxy/ProxyProfilesTab.tsx) e Provider defaults (ProviderDefaultsTab.tsx, cards por provedor nos grupos Locais (os três tipos de servidor local), Por uso e Assinaturas, com o seletor de íconesOutboundSelect.tsx). A atribuição de uma conexão fica no diálogo Manage da conexão (OutboundProxyPanel).
Provedores locais (Ollama, LM Studio, llama.cpp): N servidores por tipo, cada um uma linha de custom_providers com local_type e local_config (scheme, host, port, path_prefix, request_timeout_ms, probe_timeout_ms). Tipos, padrões e validação ficam em common/constants/local-servers.ts (LOCAL_SERVER_TYPE_DEFAULTS, defaultLocalServerConfig, invalidLocalServerConfigReason, localServerBaseUrl); o base_url é sempre derivado (scheme://host:porta/prefixo/v1) e um PUT com base_url numa linha local devolve 400. O host vale as mesmas regras das URLs: sem credenciais, sem query ou fragmento, e depois validatePublicUrl e ssrfSafeFetch como qualquer custom provider. Modelos têm preço zero (enrichCustomProviderModels com local), a conexão é auth_type 'local' (custo 0 no gravador e no Playground; warm-up THINKER_LOCAL_STREAM_WARMUP_MS) e o proxy usa o request_timeout_ms do servidor (ProviderEndpoint.timeoutMs) e só manda Authorization com chave guardada. canonicalizeAgentMessageKeys não reescreve linhas locais: os logs ficam com custom:<uuid>, para distinguir servidores. O teste de conexão (routing/custom-provider/local-server-probe.ts, POST custom-providers/probe com local_type) usa a API de cada software: Ollama /api/version, /api/tags, /api/ps e /api/show (capacidades e <arch>.context_length, até 40 modelos); LM Studio /api/v1/models, depois /api/v0/models e /v1/models; llama.cpp /health, /v1/models (router: status.value) e /props (n_ctx, visão). Depois do cadastro, o n_ctx de um servidor llama.cpp de um só modelo é relido ao vivo (model-discovery/live-context.ts, LiveContextService.apply dentro de getModelsForAgent): GET /props sem autenticação e sem redirect, timeout de 1,5 s, cache de 60 s (30 s em falha), último valor bom mantido por até 10 min em falha transitória, falha definitiva (4xx, JSON inválido, sem n_ctx) volta ao cadastrado. A janela vira min(cadastrada, n_ctx) em /v1/models, no auto/virtuais e no roteamento ciente de context window; divergência sai em WARN no máximo 1x/h. Variáveis THINKER_LIVE_CONTEXT (padrão ligado), THINKER_LIVE_CONTEXT_TTL_MS, _FAIL_TTL_MS, _TIMEOUT_MS, _STALE_MS. Conexões llama.cpp também recebem reasoning_effort como veio (ProviderEndpoint.nativeReasoningEffort; thinking desligado vira none), em vez de ter o campo removido. GET custom-providers/local-defaults devolve os padrões de cada tipo, pré-preenchidos por OLLAMA_HOST, THINKER_LMSTUDIO_BASE_URL e THINKER_LLAMACPP_BASE_URL. Fontes dos padrões (out/2026): Ollama docs/api.md e docs/faq.mdx (127.0.0.1:11434); LM Studio lmstudio.ai/docs/developer/rest (1234, Bearer opcional); llama.cpp tools/server/README.md (127.0.0.1:9931 desde ggml-org/llama.cpp#30159; antes 8080). A migração LocalServers1804500000000 marcou os custom providers criados pelos tiles antigos (pelo nome) e transformou a conexão Ollama embutida de cada tenant num servidor no OLLAMA_HOST, regravando tenant_providers.provider (mesmo id, então os harnesses continuam habilitados), as rotas de tiers, especificidades e modelos virtuais e agent_model_params. Os caminhos canônicos ollama/lmstudio/llamacpp do proxy continuam só para dados antigos. Dashboard: components/LocalServerForm.tsx, aberto pela página Local (ProviderConnectionsPage) e por deep link do tipo.
Erros do Thinker (M###): o catálogo está em common/errors/error-codes.ts e a documentação para o usuário em docs/erros.md. Levante-os com ThinkerError, nunca com HttpException pura. Todo código é gravado em uma requisição, exceto M001, M002, M003, M005 e M006, que a guard de chave levanta antes de haver tenant (UNRECORDABLE_THINKER_CODES). Um código novo precisa de mapeamento em THINKER_CODE_TO_REASON ou de declaração como não gravável (thinker-error.spec.ts falha senão) e de uma seção em docs/erros.md.
Gravação do corpo das tentativas ao provedor, por harness (ligada em harnesses novos). Variáveis THINKER_RECORDING_*:
THINKER_RECORDING_STORAGE(auto|s3|filesystem|disabled): emauto, credenciais S3 completas ganham; senão usaTHINKER_RECORDING_FILESYSTEM_PATH(/data/request-recordingsem produção).- Retenção de logs em três níveis: harness (
agents.log_retention_days, "Personalizada") > organização (tenants.log_retention_days, editável em Organização,GET|PATCH /api/v1/organizations/:id/log-retention) >THINKER_RECORDING_RETENTION_DAYS, padrão 30 (config/recording-retention.ts; valor que não seja inteiro positivo derruba o boot).NULLsegue o nível de cima ("Global" no harness). O harness lê e grava a sua emGET|PATCH /api/v1/routing/:agent/recording(retention_days,global_retention_days) e na criação (log_retention_days). - Limpeza diária às 02:00 (
database/request-recording-retention.service.ts, trava advisory): apaga os corpos gravados e zerarequest_headers,request_paramsetimelinederequestseagent_messagesexpirados, em lotes de 5000. As linhas ficam (status, tokens, custo), então gráficos e totais não mudam. A mesma limpeza apaga as entregas de gatilho já terminadas (trigger_eventsemdelivered/failed, com ostrigger_event_logsem cascata) pela mesma retenção harness > organização > ambiente. Também apagaproxy_logsexpirados pela retenção do proxy (proxy_servers.log_retention_days) > organização > ambiente;proxy_usage_dailynunca é apagado. - Cifradas com a chave atual; a re-cifragem de
PREVIOUSnão as toca. Imagens base64 embutidas são removidas. - Código:
common/services/request-recording-storage.service.ts,common/utils/request-recording-codec.tserouting/proxy/attempt-recording*.ts.
O menu Sessões (/sessions, acima de Requisições) agrupa as requisições pela sessão que o harness informa nos cabeçalhos. routing/proxy/harness-session.ts (detectHarnessSession) lê o primeiro cabeçalho preenchido nesta ordem: x-session-key (convenção do Thinker, plugin do OpenClaw, SDKs), x-openclaw-session-key, x-claude-code-session-id (Claude Code 2.1.86+; um subagente do Claude Code, que manda o mesmo id mais x-claude-code-agent-id desde a 2.1.139, vira a sessão <sessão>:<agente> filha da sessão principal, inclusive os aninhados), x-opencode-session-id/x-opencode-session, x-hermes-session-id/x-hermes-session-key, session_id/conversation_id (Codex antigo), x-session-id/x-session-affinity, e x-client-request-id só quando vem junto do originator do Codex. A sessão-mãe de um subagente do OpenCode vem de x-opencode-parent-session-id/x-parent-session-id. O gravador do proxy guarda os dois em requests.session_id e requests.parent_session_id (a migração 1805200000000-AddRequestHarnessSessions preencheu as linhas antigas a partir de request_headers). Como ficam em colunas próprias, as sessões sobrevivem à limpeza de gravações que zera request_headers.
GET /api/v1/sessions (analytics/services/sessions.service.ts) lista as sessões com atividade no período, da mais recente para a mais antiga. Cada sessão de subagente entra na sessão principal, que soma requisições, falhas, tokens (de todas as tentativas) e custo, e lista modelos e provedores das tentativas bem-sucedidas. GET /api/v1/sessions/:id devolve os totais, uma linha por sessão (principal e subagentes) e, só para owner/admin, o cliente (user-agent, projeto e cliente do OpenCode) lido dos cabeçalhos. As requisições da sessão vêm de GET /api/v1/messages?session=<id>, que filtra session_id = id OR parent_session_id = id. Uma sessão é marcada como ativa quando teve atividade nos últimos 5 minutos.
Filtros da lista (chips combináveis, guardados na URL como errors=1 e subagents=1): has_errors=true e has_subagents=true em GET /api/v1/sessions mantêm só as sessões com alguma requisição com falha ou com algum subagente, olhando a sessão inteira (principal e subagentes), como os totais da lista.
A aba Gráfico (?view=chart) usa GET /api/v1/sessions/timeline, com os mesmos filtros: por período (hora em 24h, dia em 7d/30d, semana em 90d/365d, em UTC) e sessão, devolve requisições, falhas e custo; plota só as TIMELINE_MAX_SESSIONS (300) sessões mais ativas e informa o total. O componente components/sessions/SessionsBubbleChart.tsx desenha em SVG próprio uma coluna de bolinhas por período: acima do zero as sessões sem falha no período, abaixo as com falha, as mais baratas junto ao zero; a cor é o custo no período, em faixas de uma rampa de um só matiz (--chart-1 misturado com a superfície). O hover destaca a mesma sessão em todas as colunas e o clique abre /sessions/:id.
Configurados por workspace em /triggers (owner e admin; a lista é visível a todos). O harness escolhe na aba Triggers (/harnesses/:nome/triggers) quais gatilhos aciona, podendo adicionar vários de uma vez, e com quais eventos (GET|PUT /api/v1/agents/:agent/triggers). Código em packages/backend/src/triggers/; constantes compartilhadas (tipos, eventos, variáveis, templates padrão, limites) em packages/shared/src/triggers.ts.
- Tipos:
email(Resend, Mailgun, SendGrid, que o dashboard mostra como três tipos: Email API Resend, Email API Mailgun e Email API SendGrid; sem destinatário vai para o owner do workspace),webhook(método, URL, cabeçalhos com valores cifrados, corpo, HMAC-SHA256 opcional emX-Thinker-Signature; sucesso é 2xx; a URL segueTHINKER_PROVIDER_PRIVATE_URLSao salvar e a entrega usassrfSafeFetch, que recusa metadados de nuvem),telegram(sendMessagecom parse mode HTML, tópico opcional, envio silencioso)syslog(RFC 5424 ou 3164, UDP ou TCP com uma mensagem por linha, facility e severidade por evento),gelf(GELF 1.1 por UDP com chunks: magic0x1e 0x0f, id de 8 bytes, até 128 chunks de 8180 bytes, gzip opcional; por TCP com terminador\0, sem compressão; ou por HTTP/HTTPSPOSTna URL informada, gzip opcional, viassrfSafeFetch; porta padrão 12201;short_messageefull_messagepor template,levelpela severidade do evento,hostconfigurável (source, padrão o hostname) e todas as variáveis do evento como campos_extra,buildGelf) erelp(a linha syslog do tiposyslogsobre RELP em TCP claro, porta padrão 20514:opencomrelp_version=0ecommands=syslog, umsyslogque só conta como entregue comrsp 200, eclose; outra resposta, timeout ou conexão fechada é falha e entra no retry). TLS ainda não existe em nenhum dos transportes de log. - Eventos:
limit.alert,limit.block,provider.failure(todas as rotas falharam por erro do provedor ou de transporte; no máximo um a cada 5 min por harness e provedor, comfailure.count),credential.expired(401/403 do provedor numa conexão, uma vez até ela voltar a responder; errosM###do Thinker não contam),request.debug(toda requisição, só metadados) e, assinados no próprio gatilho,harness.created/harness.deleted. A tela de teste (/triggers/test?trigger=<id>&harness=<nome>, aberta pelo ícone de teste na lista, no editor e na aba do harness) escolhe o gatilho, o evento e o harness, preenche as variáveis com valores de exemplo editáveis e entrega uma vez, na hora (POST /triggers/:id/testcomevent_typeevars; id, tipo e horário do evento, workspace, nome do gatilho e tentativa são sempre calculados pelo Thinker). O teste entrega mesmo com o gatilho desativado. - Templates:
{{variavel}}, sem lógica; variável desconhecida vira vazio. O valor é escapado pelo destino (JSON no corpo JSON do webhook, HTML no e-mail e no Telegram, URL na URL).{{event.json}}é o objeto com todas as variáveis. Lista emTRIGGER_VARIABLES. - Fluxo: produtores só publicam sinais em
TriggerSignalService(common/services/): o gravador do proxy (requisição terminal e cada tentativa), as regras de limite e a criação/remoção de harness.TriggerListenerServicedecide se há assinantes (cache de vínculos de 30 s) e grava emtrigger_eventscom as variáveis congeladas; nada disso roda no caminho da requisição. - Entrega:
TriggerWorkerServicevarre a fila a cada 10 s (e logo após novos eventos) comFOR UPDATE SKIP LOCKED, então várias réplicas convivem. Toda falha é repetida atémax_retries(0–10, padrão 3), com intervalo fixoretry_delay_seconds(1–3600, padrão 30) etimeout_seconds(1–60, padrão 10). Cada tentativa vira uma linha emtrigger_event_logscom status HTTP, erro e resposta (até 4 KB, segredos removidos). Linha presa emdeliveringpor 5 min volta para a fila. Acima de 1000 entregas pendentes num gatilho, eventos novos são descartados com aviso no log do servidor. - Segredos (chave de API, token do bot, valores de cabeçalho, segredo HMAC) ficam juntos e cifrados em
triggers.secrets_encrypted, coberto pela re-cifragem. A API devolve só dicas mascaradas; um campo ausente mantém o valor,nullapaga. - Proxy de saída: webhook, Telegram, e-mail e GELF por HTTP têm a seção "Outbound proxy" (atribuição em
proxy_assignmentscomtarget_type 'trigger': global, direto ou um perfil); cada tentativa gravaegress/egress_hopsemtrigger_event_logs. Syslog, RELP e GELF por UDP/TCP não têm (isHttpTrigger). - Migração
1804400000000-AddEventTriggers: cadaemail_provider_configsvirou um gatilho de e-mail ligado aos harnesses com regras de limite (limit.alert,limit.block). A tabela antiga ficou sem uso.EMAIL_PROVIDER/EMAIL_API_KEYno ambiente servem só para e-mails de autenticação.
Eventos do sistema na tabela system_events (migração AddSystemEvents1805600000000): tenant_id (NULL = evento da instância), level (error, warning, info), category, type, título, mensagem, entidade (entity_type, entity_id, entity_name), autor (actor_user_id, actor_ip, actor_user_agent), metadata jsonb, dedupe_key e o lido compartilhado (read_at, read_by_user_id). Constantes (níveis, categorias, tipos, categoria de cada tipo) em packages/shared/src/system-events.ts.
- Gravação:
SystemEventsService(common/services/system-events.service.ts, global).record()nunca bloqueia nem lança (insere em segundo plano; erro só no log). Deduplicação em memória pordedupe_keydentro dededupeWindowMs; comaggregate, as repetições somammetadata.countna primeira linha. Código fora do injetor do Nest (coordenador OAuth, hooks do Better Auth, catálogos) usaemitAlert/emitUserAlertdecommon/services/alert-sink.ts, que o serviço registra ao subir. Retenção:THINKER_ALERTS_RETENTION_DAYS(padrão 90), limpeza diária à 1h. - Produtores:
- Modelos:
model.added/model.removedna descoberta (model-discovery/model-change-alerts.ts, comparando comcached_modelsantes de sobrescrever; a primeira descoberta de uma conta é um único resumo, mais de 20 mudanças viram resumo, lista vazia não gera nada).model.errornoTriggerListenerServicea partir do sinalattempt(que agora trazmodeleerrorOrigin): resposta de modelo inexistente ou indisponível (isModelUnavailable: 404 citando modelo,model_not_founde afins, 400 citando o próprio modelo) ou 5 falhas seguidas do mesmo modelo numa conta; no máximo 1 por conta e modelo por hora. - Credenciais:
credential.expired(mesmo sinal 401/403 do gatilho, uma vez até a conexão voltar a responder) eoauth.refresh_failed(coordinateOAuthRefresh, no máximo 1 por credencial por hora). - Limites:
limit.alert/limit.blockno listener (uma vez por regra, tipo e período). - Triggers:
trigger.failednoTriggerWorkerServicequando uma entrega desiste. Sem recursão: falha ao entregartrigger.failed(outest) não gera outro, e o gatilho que falhou nunca recebe o aviso sobre si (excludeTriggerId). - Auditoria:
@Audit({ entity, action, ... })(alerts/audit.decorator.ts) nos handlers que criam, alteram ou removem harness, contas de provedor (padrão, custom e local), triggers, proxy (servidores, perfis, atribuições), workspaces, membros e usuários (admin). OAuditInterceptorglobal grava depois do sucesso: quem, IP (clientIpInfo), user-agent, entidade (nome lido antes da alteração quando hálookup) e só os nomes dos campos do corpo, nunca os valores. - Autenticação: hooks do Better Auth (
auth/auth-alerts.ts):user.logineuser.login_failedno after-hook de/sign-in/*(falha =ctx.context.returnedéAPIError; agrupado por e-mail+IP a cada 5 min com contagem),user.logoutno before-hook de/sign-out(a sessão ainda existe) euser.password_changedem/change-passworde/reset-password. O evento vai para cada workspace do usuário; login falho de e-mail sem conta vira evento da instância. - Catálogo:
catalog.sync_failed(instância) em falhas do models.dev, OpenRouter, catálogo do OpenCode e da descoberta de modelos, no máximo 1 por fonte por hora.
- Modelos:
- Visibilidade (
alerts/alerts.service.ts,visibilityWhere): modelos, credenciais, limites e triggers para todos os membros; auditoria e autenticação só para owner/admin; eventos da instância (catálogo, e autenticação de contas desconhecidas) só para administradores da instância. - API:
GET /api/v1/alerts/events?level=&category=&unread=1&page=&limit=(devolveevents,total,unread),POST /api/v1/alerts/events/:id/readePOST /api/v1/alerts/events/read-all(com os mesmos filtros). O primeiro que lê marca para todos. - Telas: página
/alerts(item Alertas no menu, depois de Requisições) com as abas Eventos (filtros de nível, categoria e não lidos, paginação, "lido por X em data") e Requisições com falha (o antigo conteúdo do sino). O sino mostra os alertas não lidos e as requisições com falha das últimas 24 h (estas com o controle por navegadorthinker.alertsSeenAt), e "Ver todos os alertas" leva a/alerts. - Gatilhos: os eventos de workspace viram eventos de trigger assinados no próprio gatilho (
WORKSPACE_TRIGGER_EVENTS):model.*,oauth.refresh_failed,trigger.failed,audit.*euser.*, com as variáveisalert.*,entity.*,actor.email,actor.ipeaudit.fields. OSystemEventsServiceos passa ao módulo de triggers como sinalsystem.credential.expiredelimit.*continuam eventos de harness;catalog.sync_failedé da instância e não dispara gatilhos.
O produto não faz chamadas de saída próprias: nada de telemetria, verificação de atualização, descoberta ou Sentry. Os Servidores Proxy e THINKER_HTTP_PROXY só mudam o caminho do tráfego que já existiria (não há health check periódico; o botão Testar só roda quando o usuário pede). O tráfego de saída vai só para os provedores e os destinos de gatilhos (e-mail, webhook, Telegram, Syslog) que o operador configurou, mais a exceção do ADR 0003: openrouter.ai e models.dev (ligados por padrão). O tráfego do OpenCode é tráfego do provedor e só existe enquanto há uma conta OpenCode ou OpenCode Go ativa (ADR 0009): o catálogo vem de models.opencode.ai (/experimental/v2.0/api.json, a fonte do cliente OpenCode), uma busca por instância no boot e a cada 5 minutos, condicional por ETag (model-discovery/opencode-catalog.service.ts); e cada login no Console é monitorado como um cliente aberto, com renovação do token perto do vencimento e leitura de opencode.ai/console/api/v2/config a cada 60 s por login, mesmo sem uso (routing/oauth/opencode/opencode-console-monitor.service.ts). Chave colada e public não fazem chamadas ao Console. THINKER_CATALOG_SYNC=false desliga as duas buscas de catálogo do ADR 0003 e a de models.opencode.ai; o catálogo do OpenCode passa a vir de opencode.ai/inference[/go]/v1/models no primeiro uso do provedor (também o fallback quando models.opencode.ai falha sem cópia guardada); os dados embutidos viram o fallback, incluindo o snapshot de preços que docker/pricing-snapshot.mjs grava em dist/pricing-snapshot.json durante o build da imagem (#73; --build-arg THINKER_PRICING_SNAPSHOT=false pula). Também há a busca do documento CIMD de um cliente MCP durante o OAuth (só endereços públicos, salvo THINKER_CIMD_ALLOW_PRIVATE=true). Qualquer outro destino exige um novo ADR. Lista voltada ao usuário: privacidade.md.
A referência completa, com padrões e efeitos, é docs/usuario/configuracao.md (mantenha-a em dia a cada variável nova). O arquivo-modelo é docker/.env.example e o repasse ao container fica em docker/docker-compose.yml. Variáveis só de desenvolvimento e internas:
| Variável | Uso |
|---|---|
BIND_ADDRESS |
Interface do servidor (padrão 127.0.0.1; a imagem define 0.0.0.0). |
CORS_ORIGIN |
Origem do Vite em desenvolvimento (padrão http://localhost:3000). |
FRONTEND_PORT, FRAME_ANCESTORS |
Porta extra confiável para o Better Auth; ancestrais permitidos na CSP (frame-ancestors). |
SEED_DATA |
Dados de demonstração (dev/teste). |
THINKER_E2E_ALLOW_DROP |
Só testes e2e: autoriza apagar o banco de teste. |
THINKER_MIGRATION_FORCE |
1 deixa as migrações de escopo de tenant apagarem linhas órfãs em vez de abortar o boot. Use uma vez, nunca de forma permanente. |
THINKER_FRONTEND_DIR, THINKER_EMBEDDED |
Internas: caminho do frontend compilado e flag para não iniciar sozinho. |
MIGRATION_DATABASE_URL, DATABASE_UNPOOLED_URL, BACKFILL_DATABASE_URL |
URLs diretas (sem PgBouncer) para migrações e backfills. |
BACKFILL_* |
Ajustes do worker de backfill. |
SKIP_SSRF_VALIDATION |
Só testes: com NODE_ENV=test, pula a validação SSRF salvo se for false. |
Workflows em .github/workflows/ (ações fixadas por SHA, token somente leitura, sem segredo, exceto o de release):
| Workflow | Gatilho | Conteúdo |
|---|---|---|
ci.yml |
PRs e pushes na main |
Lint, typecheck (TypeScript 6 e, em job próprio, TypeScript 7 com tsgo --noEmit), build, testes unitários por pacote (o do backend também com TZ=America/Sao_Paulo), e2e com PostgreSQL 18 (TZ=UTC e America/Sao_Paulo), scripts |
docker.yml |
PRs que tocam a imagem | Build linux/amd64 e linux/arm64, sem publicar |
docker-smoke.yml |
PRs que tocam imagem ou compose | Teste de fumaça do Compose (somente leitura) e install.sh ponta a ponta |
codeql.yml |
PRs, pushes, semanal | Análise de código |
dependency-review.yml |
PRs | Revisão de dependências |
Só os checks de ci.yml podem ser obrigatórios na main, porque os demais são filtrados por caminho.
Só o pacote thinker (packages/thinker/) é versionado; ele guarda a versão canônica da imagem. Os demais pacotes são private.
Toda entrega sai por scripts/ship.sh "<mensagem no formato Conventional Commits>" [patch|minor|major] (ADR 0005). O script:
- faz commit do que estiver pendente;
- sobe a versão de
packages/thinkere escrevedocs/releases/X.Y.Z.mda partir dos assuntos dos commits desde a última tag; - integra na
main(push direto ou, se a proteção recusar, PR com squash imediato); - cria e envia a tag anotada
vX.Y.Z.
Publicação: depois de enviar a tag, o ship.sh constrói amd64 e arm64 pelo Docker local, com a conta do Docker Hub em que ele está logado (tmsoftbrasil) e o builder local desktop-linux, publica o manifesto multi-arquitetura com as tags X.Y.Z, X.Y, latest e sha-<curto> e cria a release no GitHub com as notas. Não existe workflow de publicação na CI. Ninguém roda docker login nem manipula credenciais: o mantenedor mantém a sessão local logada. A CLI (thinker-cli) pode ser carimbada com npm run set-version --workspace=packages/cli. Antes de qualquer push, o ship.sh confere docker, jq, gh e o builder do buildx, e falha sem publicar nada se faltar algum (Docker parado, por exemplo). A cada release, scripts/pin-deploy-image.sh X.Y aponta os templates de deploy/ e o Dockerfile.heroku para a tag X.Y publicada. Se a publicação falhar depois da tag (queda no push da imagem), refaça só ela a partir de uma worktree na tag (git worktree add <dir> vX.Y.Z, o mesmo docker buildx build ... --push e o gh release create), sem rodar o ship.sh de novo, que subiria a versão.
Colocar o serviço em produção envolve três passos, nesta ordem:
- publicar no GitHub: integrar na
maine criar a release marcada como latest (ship.sh); - publicar no Docker Hub a tag
tmsoftbrasil/thinker:latest(ship.sh); - aplicar no servidor
api.tmsoft.com.br: backup do banco, atualização do contêiner e verificação.
Os detalhes (acesso, comandos, verificação e como reverter) estão em docs/dev/producao.local.md. É um arquivo só local, ignorado pelo git (*.local.md), e não sai do computador do mantenedor.
O histórico até a 0.1.2 está em packages/thinker/CHANGELOG.md (gerado pelo Changesets, aposentado depois da 0.1.4). Daqui em diante, as notas ficam só em docs/releases/.
O Thinker é desenvolvido por agentes do Claude Code. O Principal (guiado por CLAUDE.md) conversa com o mantenedor, planeja e delega aos subagentes de .claude/agents/; a tabela completa e o ciclo de vida de um agente novo estão em docs/agentes.md.
| Necessidade | Agente |
|---|---|
| O que mudou no upstream | manifest-tracker |
| Desenho de feature grande, ADR | architect |
| NestJS, TypeORM, auth, proxy | backend-dev |
| Dashboard React | frontend-dev |
| Adaptadores, catálogos, preços, OAuth de assinatura | providers |
| Imagem em Docker local, smoke e e2e | qa-docker |
| Revisão de PR | reviewer |
| CI, Dockerfile, imagem multi-arquitetura, releases, templates de deploy | devops |
| Documentação e notas de release em português | docs-ptbr |
| Regras atuais de um sistema externo (provedor por API ou assinatura, runtime local, harness) | o pesquisador da entidade (api-*, sub-*, local-*, harness-*), ver docs/externos/ |
Política de merge (ADR 0002): a main é protegida; todo trabalho vai em branch com PR (Conventional Commits). O Principal faz o squash merge quando os três gates passam:
- CI verde (até a CI existir, os testes locais dos pacotes afetados são registrados no PR);
- o
reviewerpublicouVeredito: aprovadopara o SHA do head atual (commit novo exige nova revisão; cada should-fix é corrigido ou vira issue); qa-dockeraprovado, quando o comportamento em tempo de execução mudou.
gh pr merge <n> --squash --delete-branch --match-head-commit <sha-revisado>Esperam o mantenedor: decisões de produto novas e ADRs que mudam uma anterior; arquivos de governança (CLAUDE.md, .claude/settings.json, .claude/agents/**, docs/adr/**, docs/decisoes-iniciais.md); releases (tags, GitHub Releases, Docker Hub, PR de versão, qualquer merge que publique); e Veredito: risco alto. Nunca: push na main, force push, gh pr merge --admin ou contornar a proteção pela API. Texto livre que possa citar push, main, merge ou --admin vai por arquivo (git commit -F, --body-file).
Regras adicionais para quem contribui: nenhum destino de saída novo sem ADR; nunca manipule tokens ou chaves reais; testes e2e só pelo test:e2e:docker; trabalhe em um worktree próprio quando houver agentes em paralelo.