From c18cef01b63641609ceb44470276773aaf3725d2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patrick=20Brand=C3=A3o?=
Date: Sat, 7 Nov 2026 17:49:57 -0300
Subject: [PATCH 1/2] =?UTF-8?q?docs(tutoriais):=20stress=20do=20Thinker=20?=
=?UTF-8?q?com=20o=20White=20Wizard=20(#1=20s=C3=B3=20OpenAI;=20#2=20todos?=
=?UTF-8?q?=20os=20provedores,=2012=20harnesses=20e=20loop=20sem=20fim)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Dois tutoriais testados num servidor real: Thinker (thinker-app +
thinker-postgres) atrás do Traefik, White Wizard com API, Valkey e painel,
e o wwz-load em todos os modos (rps, rampa, sweep, agentes com ferramentas,
Claude Code). O #2 sobe quatro instâncias com os seis drivers, provisiona
24 conexões e 12 harnesses com cascata de fallback, escala réplicas atrás
de um alias de rede e mantém geradores eternos até esgotar o servidor.
Inclui os números medidos e as lacunas abertas.
Co-Authored-By: Claude Opus 5.5
---
.../tutoriais/thinker-with-white-wizard-01.md | 926 ++++++++++++++++++
.../tutoriais/thinker-with-white-wizard-02.md | 834 ++++++++++++++++
2 files changed, 1760 insertions(+)
create mode 100644 docs/tutoriais/thinker-with-white-wizard-01.md
create mode 100644 docs/tutoriais/thinker-with-white-wizard-02.md
diff --git a/docs/tutoriais/thinker-with-white-wizard-01.md b/docs/tutoriais/thinker-with-white-wizard-01.md
new file mode 100644
index 00000000..46ef333d
--- /dev/null
+++ b/docs/tutoriais/thinker-with-white-wizard-01.md
@@ -0,0 +1,926 @@
+# Thinker com White Wizard #1 – Stress com OpenAI simulado
+
+Saudações. Nesse tutorial vou ensinar como montar um ambiente de **stress** para o **Thinker** usando o **White Wizard** (wwz) como provedor **OpenAI** simulado – carga pesada, erros reais de provedor e nenhum centavo gasto em token.
+
+Pré-requisitos:
+
+- **Servidor Debian** (12 ou 13) com **Docker** 29+ e **Docker Compose** 5+;
+- **Thinker 0.31.6** ou mais novo (as versões anteriores têm três bugs que esse ambiente expõe; lista na seção 8);
+- **Traefik** rodando na rede Docker **`network_public`**, com os entrypoints `web` e `websecure` e um certresolver chamado **`letsencrypt`**;
+- **DNS** apontando para o servidor: `thinker.seudominio.com.br`, `wwz-openai.seudominio.com.br` e `wwz-openai-panel.seudominio.com.br`;
+- Acesso ao repositório do **White Wizard** (`github.com/patrickbrandao/white-wizard`);
+- `jq`, `envsubst` (pacote `gettext-base`) e `openssl` no servidor.
+
+Todos os comandos rodam como **root** no servidor. Onde aparecer `seudominio.com.br`, troque pelo seu domínio.
+
+## 1 – Como funciona
+
+O teste tem três peças, cada uma num container:
+
+- **wwz-openai**: o simulador. Fala o dialeto da API da OpenAI byte a byte (`/v1/chat/completions`, `/v1/responses`, `/v1/models`), com latência, limites (RPM, TPM, concorrência) e erros configuráveis por chave;
+- **thinker-app** + **thinker-postgres**: o gateway sob teste. Recebe as requisições dos harnesses, roteia para o provedor e grava tudo (requisições, tokens, custo, gravações);
+- **wwz-load**: o gerador de carga. Joga o papel do cliente: SDK OpenAI, agente multi-turno com ferramentas, rampa, sweep, distribuído em vários workers.
+
+O caminho da requisição:
+
+```Text
+wwz-load (harness) --> thinker-app:2099 --> wwz-openai:8000
+ | |
+ | +--> thinker-postgres (requests, agent_messages)
+ +--> (opcional) Traefik TLS --> thinker-app
+```
+
+O wwz é o **boi de piranha** do teste: ele precisa ser muito mais rápido que o Thinker, senão você mede o simulador e não o gateway. Uma instância com 4 núcleos entrega milhares de req/s; o Thinker, como vamos ver, fica bem abaixo disso.
+
+## 2 – Preparando o servidor
+
+Crie os diretórios e confira a rede do Traefik:
+
+```Bash
+# diretorios do teste
+mkdir -p /opt/wwz-openai /opt/thinker /opt/thinker-stress/reports;
+
+# a rede do Traefik precisa existir
+docker network inspect network_public --format '{{.Name}} {{range .IPAM.Config}}{{.Subnet}} {{end}}';
+ # network_public 172.20.0.0/16
+```
+
+Anote o IP do Traefik nessa rede, vamos usar para o teste via TLS e no `TRUSTED_PROXIES` do Thinker:
+
+```Bash
+# IP do container do Traefik na network_public
+docker inspect traefik-app --format '{{(index .NetworkSettings.Networks "network_public").IPAddress}}';
+ # 172.20.255.253
+```
+
+#### 2.1 – Construindo as imagens do White Wizard
+
+O White Wizard não publica imagem; construa as três (servidor, painel e gerador de carga) a partir do repositório:
+
+```Bash
+# clonar o repositorio
+cd /opt/wwz-openai;
+git clone --depth 1 https://github.com/patrickbrandao/white-wizard.git src;
+cd src;
+
+# wwz (simulador), wwz-panel (painel) e wwz-load (gerador de carga + wwz-tools)
+docker build --target wwz -t white-wizard:local .;
+docker build --target wwz-panel -t white-wizard-panel:local .;
+docker build --target wwz-load -t white-wizard-load:local .;
+
+docker images | grep white-wizard;
+ # white-wizard-load:local ... 13MB
+ # white-wizard-panel:local ... 12MB
+ # white-wizard:local ... 13MB
+```
+
+## 3 – White Wizard somente OpenAI
+
+#### 3.1 – Credenciais
+
+Gere o token do control plane, a senha do painel e três chaves de inferência. Nada disso sai do servidor:
+
+```Bash
+cd /opt/wwz-openai;
+umask 077;
+( echo "DOMINIO=seudominio.com.br";
+ echo "WWZ_ADMIN_TOKEN=$(openssl rand -hex 24)";
+ echo "WWZ_PANEL_ADMIN_PASSWORD=$(openssl rand -hex 12)";
+ echo "WWZ_KEY_1=wwz-key-$(openssl rand -hex 12)";
+ echo "WWZ_KEY_2=wwz-key-$(openssl rand -hex 12)";
+ echo "WWZ_KEY_3=wwz-key-$(openssl rand -hex 12)";
+) > .env;
+chmod 600 .env;
+```
+
+#### 3.2 – Configuração do simulador
+
+Cada chave cai numa **conta** com limites e perfis próprios. Assim a mesma instância simula três "contas OpenAI" bem diferentes:
+
+- **conta-01**: sem limite, sem erro, latência zero. É a que mede o teto do Thinker;
+- **conta-02**: limites de um tier alto, 5% de erros e latência realista;
+- **conta-03**: limites apertados (600 RPM, 50 simultâneas) e **20%** de erros, inclusive no meio do stream. É a que testa o failover.
+
+Crie o arquivo `/opt/wwz-openai/wwz.yaml`:
+
+```YAML
+# /opt/wwz-openai/wwz.yaml
+# White Wizard somente OpenAI, para estressar o Thinker.
+server:
+ listen: ":8000"
+ auth: keys
+ reuseport: true
+ concurrency: 1000000
+ trusted_proxies: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16]
+
+admin:
+ listen: ":9000"
+
+# eventos no Valkey para o painel (wwz-panel)
+store:
+ event_stream: true
+ event_sample: 1
+
+drivers:
+ - {name: openai}
+
+# contas: da 01 (ilimitada e perfeita) a 03 (apertada e com falhas)
+accounts:
+ - {id: conta-01}
+ - {id: conta-02, limits: {rpm: 60000, tpm: 10000000, concurrency: 5000}}
+ - {id: conta-03, limits: {rpm: 600, tpm: 100000, concurrency: 50}}
+
+keys:
+ - {key: ${WWZ_KEY_1}, label: conta-01, account: conta-01, errors: sem-erro, latency: rapida}
+ - {key: ${WWZ_KEY_2}, label: conta-02, account: conta-02, errors: erro-05, latency: realista}
+ - {key: ${WWZ_KEY_3}, label: conta-03, account: conta-03, errors: erro-20, latency: realista}
+
+profiles:
+ latency:
+ rapida: {ttft: 0ms, itl: 0ms}
+ realista: {queue: {dist: lognormal, median: 20ms, p99: 300ms}, ttft: {dist: normal, mean: 450ms, stddev: 120ms}, itl: {dist: normal, mean: 18ms, stddev: 4ms}}
+ errors:
+ sem-erro: {rate: 0}
+ erro-05: {rate: 5, types: [rate_limit, server_error, overloaded], mid_stream_rate: 1}
+ erro-20: {rate: 20, types: [rate_limit, server_error, overloaded, bad_gateway, gateway_timeout, connection_reset], mid_stream_rate: 6, mid_stream_types: [stream_truncated, stream_error_event]}
+
+models:
+ - id: wwz-fast
+ owned_by: white-wizard
+ protocols: [openai]
+ context_window: 128000
+ capabilities: {tools: true, reasoning: true, json_schema: true}
+ generation: default
+ - id: gpt-5-sim
+ owned_by: white-wizard
+ protocols: [openai]
+ context_window: 400000
+ capabilities: {tools: true, reasoning: true, json_schema: true}
+ latency: realista
+ generation: default
+```
+
+**CUIDADO**: o wwz **não expande variáveis** dentro do YAML. Renderize com `envsubst` (só as três chaves) e valide antes de subir:
+
+```Bash
+cd /opt/wwz-openai;
+set -a; . ./.env; set +a;
+mkdir -p run;
+envsubst '${WWZ_KEY_1} ${WWZ_KEY_2} ${WWZ_KEY_3}' < wwz.yaml > run/wwz.yaml;
+chmod 644 run/wwz.yaml;
+
+# validar (o -config e obrigatorio; sem ele o arquivo e lido como cenario)
+docker run --rm -v $PWD/run/wwz.yaml:/w.yaml:ro white-wizard:local validate -config /w.yaml | head -1;
+ # ok config /w.yaml (2 models, 1 drivers, 3 keys)
+```
+
+#### 3.3 – Compose (API, Valkey e painel admin)
+
+O painel (`wwz-panel`) lê os eventos e os contadores pelo **Valkey**, então ele entra junto. Crie `/opt/wwz-openai/docker-compose.yml`:
+
+```YAML
+# /opt/wwz-openai/docker-compose.yml
+# White Wizard somente OpenAI: API (wwz-openai), Valkey e painel admin.
+name: wwz-openai
+
+services:
+ wwz-openai:
+ image: white-wizard:local
+ container_name: wwz-openai
+ restart: unless-stopped
+ read_only: true
+ tmpfs: [/run, /tmp]
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ cpus: ${WWZ_CPUS:-4}
+ mem_limit: ${WWZ_MEM:-8g}
+ ulimits:
+ nofile: {soft: 1048576, hard: 1048576}
+ environment:
+ WWZ_CONFIG: /etc/wwz/wwz.yaml
+ WWZ_ADMIN_TOKEN: ${WWZ_ADMIN_TOKEN}
+ WWZ_VALKEY_ADDR: wwz-openai-valkey:6379
+ WWZ_LOG_LEVEL: warn
+ GOGC: "200"
+ volumes: ["./run/wwz.yaml:/etc/wwz/wwz.yaml:ro"]
+ depends_on:
+ wwz-openai-valkey: {condition: service_healthy}
+ healthcheck:
+ test: ["CMD", "/usr/local/bin/healthcheck", "http://127.0.0.1:9000/health/ready"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ networks: [interna, network_public]
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: network_public
+ traefik.http.routers.wwz-openai.entrypoints: web,websecure
+ traefik.http.routers.wwz-openai.rule: Host(`wwz-openai.${DOMINIO}`)
+ traefik.http.routers.wwz-openai.tls: "true"
+ traefik.http.routers.wwz-openai.tls.certresolver: letsencrypt
+ traefik.http.routers.wwz-openai.service: wwz-openai
+ traefik.http.services.wwz-openai.loadbalancer.server.port: "8000"
+
+ wwz-openai-valkey:
+ image: valkey/valkey:8.1-alpine
+ container_name: wwz-openai-valkey
+ restart: unless-stopped
+ user: "999:999"
+ command: ["valkey-server", "--save", "", "--appendonly", "no", "--maxmemory", "2gb"]
+ tmpfs: [/run, /data]
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ healthcheck:
+ test: ["CMD", "valkey-cli", "ping"]
+ interval: 5s
+ timeout: 2s
+ retries: 10
+ networks: [interna]
+
+ wwz-openai-panel:
+ image: white-wizard-panel:local
+ container_name: wwz-openai-panel
+ restart: unless-stopped
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ environment:
+ WWZ_PANEL_LISTEN: ":8100"
+ WWZ_PANEL_DB: /data/panel.db
+ WWZ_PANEL_PUBLIC_URL: https://wwz-openai-panel.${DOMINIO}
+ WWZ_PANEL_VALKEY_ADDR: wwz-openai-valkey:6379
+ WWZ_PANEL_WWZ_ADMIN_URL: http://wwz-openai:9000
+ WWZ_ADMIN_TOKEN: ${WWZ_ADMIN_TOKEN}
+ WWZ_PANEL_ADMIN_USER: admin
+ WWZ_PANEL_ADMIN_PASSWORD: ${WWZ_PANEL_ADMIN_PASSWORD}
+ volumes: [panel-data:/data]
+ depends_on:
+ wwz-openai: {condition: service_healthy}
+ healthcheck:
+ test: ["CMD", "/usr/local/bin/healthcheck", "http://127.0.0.1:8100/health/ready"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ networks: [interna, network_public]
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: network_public
+ traefik.http.routers.wwz-openai-panel.entrypoints: web,websecure
+ traefik.http.routers.wwz-openai-panel.rule: Host(`wwz-openai-panel.${DOMINIO}`)
+ traefik.http.routers.wwz-openai-panel.tls: "true"
+ traefik.http.routers.wwz-openai-panel.tls.certresolver: letsencrypt
+ traefik.http.routers.wwz-openai-panel.service: wwz-openai-panel
+ traefik.http.services.wwz-openai-panel.loadbalancer.server.port: "8100"
+
+volumes:
+ panel-data: {name: wwz-openai-panel-data}
+
+networks:
+ interna:
+ name: wwz-openai-interna
+ network_public:
+ external: true
+ name: network_public
+```
+
+Repare: o **control plane** (porta 9000) não tem router no Traefik. Ele só é alcançado pela rede Docker, pelo painel e pelo `wwz-load top`.
+
+#### 3.4 – Subindo e testando
+
+Para subir a stack, execute:
+
+```Bash
+cd /opt/wwz-openai;
+docker compose up -d --wait;
+docker ps --filter name=wwz-openai --format '{{.Names}} {{.Status}}';
+ # wwz-openai-panel Up 5 seconds (healthy)
+ # wwz-openai Up 10 seconds (healthy)
+ # wwz-openai-valkey Up 15 seconds (healthy)
+```
+
+Teste a API simulada pela rede Docker (é por ela que o Thinker vai falar):
+
+```Bash
+cd /opt/wwz-openai; . ./.env;
+
+# listar modelos
+docker run --rm --network network_public curlimages/curl:8.11.1 -s \
+ http://wwz-openai:8000/v1/models -H "Authorization: Bearer $WWZ_KEY_1" | jq -r '.data[].id';
+ # wwz-fast
+ # gpt-5-sim
+
+# um chat completion
+docker run --rm --network network_public curlimages/curl:8.11.1 -s \
+ http://wwz-openai:8000/v1/chat/completions -H "Authorization: Bearer $WWZ_KEY_1" \
+ -H 'content-type: application/json' \
+ -d '{"model":"wwz-fast","messages":[{"role":"user","content":"oi"}],"max_tokens":20}' \
+ | jq -c '{model, usage}';
+ # {"model":"wwz-fast","usage":{"prompt_tokens":8,"completion_tokens":20,"total_tokens":28,...}}
+
+# contadores do control plane
+docker run --rm --network network_public curlimages/curl:8.11.1 -s \
+ http://wwz-openai:9000/admin/stats -H "Authorization: Bearer $WWZ_ADMIN_TOKEN" | jq -c .totals;
+ # {"requests":1,"in_flight":0,"streams_open":0,"errors_injected":0,...}
+```
+
+#### 3.5 – Painel admin
+
+Abra `https://wwz-openai-panel.seudominio.com.br` e entre com o usuário **admin** e a senha do `.env`:
+
+```Bash
+grep WWZ_PANEL_ADMIN_PASSWORD /opt/wwz-openai/.env;
+```
+
+No painel você vê uso por chave, eventos, bloqueios e políticas. Para mexer no simulador com o teste rodando (sem reiniciar nada), use a API admin – por exemplo, fazer o `wwz-fast` devolver 500 em tudo e depois voltar ao normal:
+
+```Bash
+cd /opt/wwz-openai; . ./.env;
+A="docker run --rm -i --network network_public curlimages/curl:8.11.1 -s";
+
+# wwz-fast passa a dar 100% de server_error
+$A http://wwz-openai:9000/admin/config -H "Authorization: Bearer $WWZ_ADMIN_TOKEN" \
+ -H 'content-type: application/json' \
+ -d '{"models":{"wwz-fast":{"errors":{"rate":100,"types":["server_error"]}}}}';
+
+# volta ao normal
+$A http://wwz-openai:9000/admin/config -H "Authorization: Bearer $WWZ_ADMIN_TOKEN" \
+ -H 'content-type: application/json' -d '{"models":{"wwz-fast":{"errors":"none"}}}';
+
+# zera os contadores entre rodadas
+$A -X POST http://wwz-openai:9000/admin/reset -H "Authorization: Bearer $WWZ_ADMIN_TOKEN";
+```
+
+## 4 – Rodando o Thinker
+
+#### 4.1 – Credenciais
+
+O Thinker exige duas chaves diferentes (sessão e criptografia em repouso). O token de setup fixo deixa criar o primeiro admin sem caçar o token no log:
+
+```Bash
+cd /opt/thinker;
+umask 077;
+( echo "DOMINIO=seudominio.com.br";
+ echo "THINKER_VERSION=latest";
+ echo "BETTER_AUTH_SECRET=$(openssl rand -hex 32)";
+ echo "THINKER_ENCRYPTION_KEY=$(openssl rand -hex 32)";
+ echo "THINKER_SETUP_TOKEN=$(openssl rand -hex 24)";
+ echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)";
+ echo "THINKER_ADMIN_EMAIL=stress@thinker.local";
+ echo "THINKER_ADMIN_PASSWORD=$(openssl rand -hex 16)";
+ echo "TRUSTED_PROXIES=172.20.255.253";
+) > .env;
+chmod 600 .env;
+```
+
+Troque o `TRUSTED_PROXIES` pelo IP do Traefik que você anotou na seção 2.
+
+#### 4.2 – Compose
+
+O compose oficial do Thinker é feito para uso normal. Para stress, quatro coisas mudam e são **obrigatórias**:
+
+- **Guardrails do proxy**: por padrão o Thinker aceita só **10** requisições simultâneas por tenant (`THINKER_CONCURRENCY_MAX`), **200/min** por tenant (`THINKER_RATE_MAX_REQUESTS`) e **500/min** por IP (`THINKER_IP_RATE_MAX_REQUESTS`). Sem levantar isso, o teste mede o erro **M201**, não o gateway;
+- **URLs privadas**: o simulador está em `http://wwz-openai:8000`, um endereço privado. `THINKER_PROVIDER_PRIVATE_URLS=allow` libera;
+- **Catálogos públicos desligados** (`THINKER_CATALOG_SYNC=false`): o teste não depende da internet;
+- **Postgres afinado**: `max_connections`, `shared_buffers`, `synchronous_commit=off` e `pg_stat_statements` para ver o que cada requisição custa no banco.
+
+Crie `/opt/thinker/docker-compose.yml`:
+
+```YAML
+# /opt/thinker/docker-compose.yml
+# Thinker para teste de stress (thinker-app + thinker-postgres) atras do Traefik.
+name: thinker
+
+services:
+ thinker-postgres:
+ image: postgres:18-alpine
+ container_name: thinker-postgres
+ restart: unless-stopped
+ shm_size: 2g
+ cpus: 8
+ mem_limit: 16g
+ environment:
+ POSTGRES_USER: thinker
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: thinker
+ command:
+ - postgres
+ - -c
+ - max_connections=400
+ - -c
+ - shared_buffers=4GB
+ - -c
+ - effective_cache_size=12GB
+ - -c
+ - work_mem=16MB
+ - -c
+ - maintenance_work_mem=512MB
+ - -c
+ - wal_buffers=64MB
+ - -c
+ - max_wal_size=8GB
+ - -c
+ - checkpoint_timeout=15min
+ - -c
+ - synchronous_commit=off
+ - -c
+ - random_page_cost=1.1
+ - -c
+ - track_io_timing=on
+ - -c
+ - shared_preload_libraries=pg_stat_statements
+ - -c
+ - pg_stat_statements.track=top
+ volumes: [pgdata:/var/lib/postgresql]
+ healthcheck:
+ test: pg_isready -U thinker
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ security_opt: ["no-new-privileges:true"]
+ networks: [interna]
+
+ thinker-app:
+ image: tmsoftbrasil/thinker:${THINKER_VERSION:-latest}
+ container_name: thinker-app
+ restart: unless-stopped
+ stop_grace_period: 30s
+ cpus: 8
+ mem_limit: 8g
+ pids_limit: 4096
+ ulimits:
+ nofile: {soft: 1048576, hard: 1048576}
+ read_only: true
+ tmpfs: ["/tmp:size=256m"]
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ environment:
+ PORT: "2099"
+ DATABASE_URL: postgresql://thinker:${POSTGRES_PASSWORD}@thinker-postgres:5432/thinker
+ BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ THINKER_ENCRYPTION_KEY: ${THINKER_ENCRYPTION_KEY}
+ THINKER_SETUP_TOKEN: ${THINKER_SETUP_TOKEN}
+ BETTER_AUTH_URL: https://thinker.${DOMINIO}
+ TRUSTED_PROXIES: ${TRUSTED_PROXIES}
+ # provedores simulados na rede Docker (http://wwz-*:8000) sao URLs privadas
+ THINKER_PROVIDER_PRIVATE_URLS: allow
+ # sem catalogos publicos: o teste nao depende da internet
+ THINKER_CATALOG_SYNC: "false"
+ # guardrails do proxy levantados para o stress (padroes: 10, 200/min, 500/min)
+ THINKER_CONCURRENCY_MAX: ${THINKER_CONCURRENCY_MAX:-100000}
+ THINKER_RATE_MAX_REQUESTS: ${THINKER_RATE_MAX_REQUESTS:-100000000}
+ THINKER_IP_RATE_MAX_REQUESTS: ${THINKER_IP_RATE_MAX_REQUESTS:-100000000}
+ THROTTLE_LIMIT: "10000"
+ DB_POOL_MAX: ${DB_POOL_MAX:-80}
+ AUTH_DB_POOL_MAX: "10"
+ PROVIDER_TIMEOUT_MS: "180000"
+ THINKER_RECORDING_STORAGE: auto
+ THINKER_RECORDING_FILESYSTEM_PATH: /data/request-recordings
+ NODE_OPTIONS: --max-old-space-size=6144
+ UV_THREADPOOL_SIZE: "16"
+ volumes: [recordings:/data/request-recordings]
+ depends_on:
+ thinker-postgres: {condition: service_healthy}
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:2099/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+ interval: 15s
+ timeout: 5s
+ start_period: 90s
+ retries: 3
+ networks:
+ interna: {}
+ network_public:
+ # alias comum: as replicas do tutorial #2 entram nele
+ aliases: [thinker-pool]
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: network_public
+ traefik.http.routers.thinker.entrypoints: web,websecure
+ traefik.http.routers.thinker.rule: Host(`thinker.${DOMINIO}`)
+ traefik.http.routers.thinker.tls: "true"
+ traefik.http.routers.thinker.tls.certresolver: letsencrypt
+ traefik.http.services.thinker.loadbalancer.server.port: "2099"
+
+volumes:
+ pgdata: {name: thinker_pgdata}
+ recordings: {name: thinker_recordings}
+
+networks:
+ interna:
+ name: thinker-interna
+ internal: true
+ network_public:
+ external: true
+ name: network_public
+```
+
+**CRÍTICO**: a rede `thinker-interna` é `internal: true`. O Postgres nunca fica alcançável fora do par app/banco, nem pelo Traefik.
+
+#### 4.3 – Subindo
+
+Para subir o Thinker e ligar a extensão de estatísticas do Postgres, execute:
+
+```Bash
+cd /opt/thinker;
+docker compose up -d --wait;
+docker exec thinker-postgres psql -U thinker -d thinker -qc 'create extension if not exists pg_stat_statements';
+
+# saude pelo Traefik, com TLS (o certificado sai em alguns segundos)
+curl -s --resolve thinker.seudominio.com.br:443:172.20.255.253 https://thinker.seudominio.com.br/api/v1/health;
+ # {"status":"healthy",...}
+```
+
+**CUIDADO**: de dentro do servidor, `curl https://thinker.seudominio.com.br` pode ficar pendurado para sempre. Muito NAT não faz **hairpin** (o IP público não volta para a própria máquina). Por isso o `--resolve` para o IP do Traefik; da sua máquina, o domínio responde normalmente.
+
+#### 4.4 – Primeiro administrador
+
+O assistente `/setup` aceita o token fixo do `.env`. Crie o admin pela API, de dentro da rede Docker:
+
+```Bash
+cd /opt/thinker; . ./.env;
+jq -n --arg e "$THINKER_ADMIN_EMAIL" --arg p "$THINKER_ADMIN_PASSWORD" --arg t "$THINKER_SETUP_TOKEN" \
+ '{email: $e, name: "Stress Admin", password: $p, setupToken: $t}' \
+ | docker run --rm -i --network network_public curlimages/curl:8.11.1 -s \
+ -X POST http://thinker-app:2099/api/v1/setup/admin -H 'content-type: application/json' --data-binary @-;
+ # {"ok":true}
+```
+
+Com esse e-mail e senha você entra no dashboard em `https://thinker.seudominio.com.br`.
+
+#### 4.5 – O CLI dentro do container
+
+A imagem traz o CLI `thinker`. Dentro do container ele usa a própria `THINKER_ENCRYPTION_KEY` para gerar um token root de 5 minutos, sem login. Crie dois atalhos:
+
+```Bash
+# sem stdin (a maioria dos comandos)
+alias thk='docker exec -e XDG_CONFIG_HOME=/tmp/thinker-cli thinker-app thinker';
+# com stdin (para passar credenciais por pipe)
+alias thki='docker exec -i -e XDG_CONFIG_HOME=/tmp/thinker-cli thinker-app thinker';
+
+thk doctor | jq -c '.checks[] | {name, ok}';
+ # {"name":"config","ok":true}
+ # {"name":"host","ok":true}
+ # {"name":"auth","ok":true}
+ # ...
+```
+
+Dois detalhes que custam caro se ignorados:
+
+- **`XDG_CONFIG_HOME=/tmp/thinker-cli`**: o container é `read_only` e o CLI guarda um cache das chaves dos harnesses. O `/tmp` é o único lugar gravável;
+- **`-i` só quando precisa**: `docker exec -i` dentro de um script lê o **stdin do script inteiro** e engole os comandos seguintes. Em loop ou heredoc, use `thk ... /dev/null;
+done;
+thk provider refresh openai --agent wwz-generico >/dev/null;
+thk provider list | jq -r '.providers[] | select(.provider=="openai") | .connections[] | "\(.label) \(.base_url) \(.cached_model_count)"';
+ # conta-01 http://wwz-openai:8000 2
+ # conta-02 http://wwz-openai:8000 2
+ # conta-03 http://wwz-openai:8000 2
+```
+
+#### 5.3 – Rota padrão
+
+Aponte o `auto` do harness para o `wwz-fast` da conta-01 e mande uma requisição real:
+
+```Bash
+thk agent configure wwz-generico --models wwz-fast --provider openai --key-label conta-01 | jq -c .route.override_route;
+ # {"provider":"openai","authType":"api_key","model":"wwz-fast","keyLabel":"conta-01"}
+
+thk routing test wwz-generico "diga ok" | jq -c '{ok, served_model, duration_ms}';
+ # {"ok":true,"served_model":"wwz-fast","duration_ms":88}
+```
+
+#### 5.4 – Modelos virtuais para o teste
+
+Dois **modelos virtuais** completam o cenário:
+
+- **wwz-stream**: mesma rota, em modo `stream` (a rota padrão é `buffered`: o Thinker segura a resposta inteira para poder trocar de provedor no meio);
+- **wwz-falhas**: principal na **conta-03** (20% de erros, limites apertados) com fallback no `wwz-fast`. É aqui que o failover do Thinker apanha.
+
+```Bash
+thk routing virtual create wwz-generico --name wwz-stream --model wwz-fast --provider openai \
+ --key-label conta-01 --response-mode stream | jq -c '{name, response_mode}';
+thk routing virtual create wwz-generico --name wwz-falhas --model wwz-fast --provider openai \
+ --key-label conta-03 --fallback gpt-5-sim | jq -c '{name}';
+
+thk routing virtual list wwz-generico | jq -r '.[] | .name';
+ # wwz-stream
+ # wwz-falhas
+```
+
+#### 5.5 – Chave do harness para o gerador
+
+Guarde a chave do harness e o resto do que o gerador precisa num `.env` próprio:
+
+```Bash
+cd /opt/thinker-stress;
+umask 077;
+( echo "THINKER_AGENT_KEY=$(thk agent key show wwz-generico --raw)";
+ grep -E '^(WWZ_KEY_1|WWZ_ADMIN_TOKEN)=' /opt/wwz-openai/.env;
+ echo "THINKER_HOST=thinker.seudominio.com.br";
+ echo "TRAEFIK_IP=172.20.255.253";
+ echo "WWZ_LOAD_IMAGE=white-wizard-load:local";
+) > .env;
+chmod 600 .env;
+```
+
+## 6 – Ferramentas de stress
+
+O `wwz-load` roda num container descartável na `network_public`, falando direto com `thinker-app:2099`. Para não decorar flag, eu enlatei os modos num script.
+
+Crie `/opt/thinker-stress/stress.sh`:
+
+```Bash
+#!/bin/bash
+# /opt/thinker-stress/stress.sh
+# Stress do Thinker com o wwz-load (White Wizard).
+# Uso: ./stress.sh [flags extras do wwz-load]
+# base carga direto no wwz-openai (teto do simulador)
+# conc N concorrencia fixa N pelo Thinker (padrao 100)
+# rps N taxa fixa N req/s pelo Thinker (padrao 200)
+# rampa taxa crescendo de 50 a 2000 req/s
+# sweep degraus 50,100,200,400,800,1600 req/s
+# agentes N N agentes multi-turno com ferramentas (padrao 100)
+# traefik N concorrencia N passando pelo Traefik com TLS (padrao 100)
+# falhas N concorrencia N no modelo virtual com falhas e fallback
+# top painel ao vivo do wwz-openai
+set -euo pipefail
+cd "$(dirname "$0")"
+. ./.env
+
+IMG=${WWZ_LOAD_IMAGE:-white-wizard-load:local}
+NET=${NET:-network_public}
+THINKER=${THINKER:-http://thinker-app:2099}
+DUR=${DUR:-60s}
+MODEL=${MODEL:-auto}
+STAMP=$(date -u +%Y%m%d-%H%M%S)
+mkdir -p reports && chmod 777 reports
+
+# executa o wwz-load num container descartavel na rede dos servicos
+wl() {
+ docker run --rm --network "$NET" \
+ --ulimit nofile=1048576:1048576 \
+ ${EXTRA_DOCKER_ARGS:-} \
+ -e WWZ_LOAD_KEY="${KEY:-$THINKER_AGENT_KEY}" \
+ -e WWZ_ADMIN_TOKEN="$WWZ_ADMIN_TOKEN" \
+ -v "$PWD/reports:/reports" "$IMG" wwz-load "$@"
+}
+
+modo=${1:-conc}; shift || true
+case "$modo" in
+ base)
+ KEY=$WWZ_KEY_1 wl run --target http://wwz-openai:8000 --concurrency "${1:-100}" \
+ --duration "$DUR" --admin http://wwz-openai:9000 --output json --out /reports/base-$STAMP.json "${@:2}" ;;
+ conc)
+ wl run --target "$THINKER" --model "$MODEL" --concurrency "${1:-100}" --duration "$DUR" \
+ --warmup 5s --output json --out /reports/conc-$STAMP.json "${@:2}" ;;
+ rps)
+ wl run --target "$THINKER" --model "$MODEL" --rps "${1:-200}" --duration "$DUR" \
+ --max-inflight 20000 --output json --out /reports/rps-$STAMP.json "${@:2}" ;;
+ rampa)
+ wl run --target "$THINKER" --model "$MODEL" --ramp 50:2000:5m --step-duration 15s \
+ --max-inflight 20000 --output json --out /reports/rampa-$STAMP.json "$@" ;;
+ sweep)
+ wl run --target "$THINKER" --model "$MODEL" --sweep --sweep-rates 50,100,200,400,800,1600 \
+ --step-duration 30s --sweep-stop-error-pct 30 --max-inflight 20000 \
+ --output json --out /reports/sweep-$STAMP.json "$@" ;;
+ agentes)
+ wl agents --target "$THINKER" --model "$MODEL" --agents "${1:-100}" --profile mixed \
+ --turns 5 --duration "$DUR" --retries 2 --output json --out /reports/agentes-$STAMP.json "${@:2}" ;;
+ traefik)
+ EXTRA_DOCKER_ARGS="--add-host $THINKER_HOST:$TRAEFIK_IP" \
+ wl run --target "https://$THINKER_HOST" --model "$MODEL" --concurrency "${1:-100}" \
+ --duration "$DUR" --output json --out /reports/traefik-$STAMP.json "${@:2}" ;;
+ falhas)
+ wl run --target "$THINKER" --model wwz-falhas --concurrency "${1:-100}" --duration "$DUR" \
+ --output json --out /reports/falhas-$STAMP.json "${@:2}" ;;
+ top)
+ docker run --rm -it --network "$NET" -e WWZ_ADMIN_TOKEN="$WWZ_ADMIN_TOKEN" "$IMG" \
+ wwz-load top --admin http://wwz-openai:9000 ;;
+ *) sed -n 3,15p "$0"; exit 2 ;;
+esac
+
+# resumo do ultimo relatorio
+ultimo=$(ls -t reports/*.json 2>/dev/null | head -1 || true)
+[ -n "$ultimo" ] && jq -c '(.total // {}) | {requests, ok, errors, rps, ttft_p50: .ttft_ms.p50, ttft_p99: .ttft_ms.p99, e2e_p99: .e2e_ms.p99, errors_by_type, sessions}' "$ultimo";
+```
+
+Dê permissão de execução:
+
+```Bash
+chmod +x /opt/thinker-stress/stress.sh;
+```
+
+#### 6.1 – Linha de base: o simulador sozinho
+
+Antes de culpar o Thinker, meça o wwz sem ele:
+
+```Bash
+cd /opt/thinker-stress;
+DUR=20s ./stress.sh base 20;
+ # {"requests":141089,"ok":141089,"errors":0,"rps":7054.45,"ttft_p50":1.411,"ttft_p99":6.895,...}
+```
+
+Esse número é o seu **teto**: tudo que o Thinker entregar abaixo dele é o custo do gateway.
+
+#### 6.2 – Concorrência fixa, taxa fixa, rampa e sweep
+
+Os quatro modos de carga do `wwz-load`, pelo Thinker:
+
+- **conc N**: malha fechada, N clientes que mandam a próxima assim que a anterior volta. Mede vazão máxima;
+- **rps N**: malha aberta, uma partida a cada 1/N s, não importa a latência. Mede como a fila cresce quando o gateway não aguenta;
+- **rampa**: a taxa sobe linearmente; mostra onde o joelho da curva aparece;
+- **sweep**: degraus de taxa e parada automática quando os erros passam de 30%.
+
+```Bash
+cd /opt/thinker-stress;
+# vazao maxima: 100 clientes
+DUR=20s ./stress.sh conc 100;
+ # {"requests":2458,"ok":2458,"errors":0,"rps":122.9,"ttft_p50":804.863,"ttft_p99":919.551,...}
+
+# abaixo do teto: 100 req/s
+DUR=20s ./stress.sh rps 100;
+ # {"requests":2000,"ok":2000,"errors":0,"rps":100,"ttft_p50":7.087,"ttft_p99":26.943,...}
+
+# degraus ate quebrar
+./stress.sh sweep;
+jq -c '.steps[] | {target_rps, rps, errors, p50: .ttft_ms.p50}' $(ls -t reports/sweep-*.json | head -1);
+ # {"target_rps":50,"rps":50,"errors":0,"p50":6.351}
+ # {"target_rps":100,"rps":100,"errors":0,"p50":6.927}
+ # {"target_rps":200,"rps":200,"errors":942,"p50":2711.551}
+ # {"target_rps":400,"rps":400,"errors":7856,"p50":5881.855}
+ # ...
+
+# streaming de verdade (modelo virtual)
+MODEL=wwz-stream ./stress.sh conc 200;
+```
+
+Leia os dois primeiros números juntos: com 100 clientes o Thinker entrega ~123 req/s e o TTFT p50 vai a 800 ms – é **fila**, não processamento. Com 100 req/s (abaixo do teto) o mesmo TTFT p50 cai para **7 ms**. O sweep mostra o joelho entre 100 e 200 req/s: dali para cima a fila cresce até o timeout de 60 s do cliente.
+
+Os relatórios completos (percentis, tokens/s, erros por tipo) ficam em `/opt/thinker-stress/reports/*.json`.
+
+#### 6.3 – Agentes multi-turno com ferramentas
+
+O `wwz-load agents` é o **simulador de agente**: N agentes, cada um com sessões de vários turnos, nos perfis `coder` (ler, escrever, editar arquivo, rodar comando), `pesquisa` (busca e fetch) e `chat` (histórico crescente). Cada tool call que o modelo pede é **executada** pelo catálogo do `wwz-tools` e o resultado volta na conversa, como num harness de verdade:
+
+```Bash
+cd /opt/thinker-stress;
+DUR=60s ./stress.sh agentes 200;
+ # {"requests":8741,"ok":8741,"errors":0,"rps":129.54,...,"sessions":{"started":1359,"completed":1359,"failed":0,...}}
+
+# ferramentas chamadas e resultado
+jq -c '.total.tools' $(ls -t reports/agentes-*.json | head -1);
+ # {"calls_by_name":{"edit_file":314,"fetch_url":605,"list_dir":319,"read_file":943,...},"outcomes":{"natural_error":2182,"success":1453}}
+```
+
+O histórico cresce a cada turno, então o tamanho do corpo também cresce: é o teste que mais pesa nas **gravações** e no Postgres.
+
+#### 6.4 – Pelo Traefik, com TLS
+
+Para incluir o proxy reverso e o TLS na medição, o gerador resolve o domínio público para o IP interno do Traefik (o NAT de muitos servidores não faz **hairpin**: de dentro do servidor, o IP público não volta para ele mesmo):
+
+```Bash
+cd /opt/thinker-stress;
+DUR=20s ./stress.sh traefik 50;
+ # {"requests":2531,"ok":2531,"errors":0,"rps":126.55,"ttft_p50":398.335,...}
+```
+
+#### 6.5 – Falhas e failover
+
+O virtual `wwz-falhas` manda tudo para a conta-03 (20% de erros e 50 simultâneas). Com 200 clientes, o limite estoura e os erros do meio do stream aparecem. Compare o que o cliente recebeu com o que o simulador injetou:
+
+```Bash
+cd /opt/thinker-stress;
+DUR=20s ./stress.sh falhas 200;
+ # {"requests":2481,"ok":2477,"errors":4,...,"errors_by_type":{"stream_error/server_error":4}}
+
+docker run --rm --network network_public curlimages/curl:8.11.1 -s \
+ http://wwz-openai:9000/admin/stats -H "Authorization: Bearer $(grep ^WWZ_ADMIN_TOKEN= .env | cut -d= -f2-)" \
+ | jq -c '.totals | {requests, errors_injected, rate_limited}';
+```
+
+Cada erro injetado que **não** chegou ao cliente é um fallback que funcionou. Os poucos que chegam são erros **no meio do stream**: depois que o primeiro byte saiu, não há como trocar de provedor.
+
+## 7 – Observando
+
+Com uma carga rodando, abra outros terminais.
+
+Para ver CPU e memória dos containers, execute:
+
+```Bash
+docker stats thinker-app thinker-postgres wwz-openai;
+```
+
+Para o painel ao vivo do simulador (req/s, erros, TTFT por modelo e por chave), execute:
+
+```Bash
+/opt/thinker-stress/stress.sh top;
+```
+
+Para ver o que cada requisição custa no banco, zere as estatísticas, rode uma carga e liste as consultas mais caras:
+
+```Bash
+docker exec thinker-postgres psql -U thinker -d thinker -qc 'select pg_stat_statements_reset()';
+DUR=20s /opt/thinker-stress/stress.sh conc 100;
+docker exec thinker-postgres psql -U thinker -d thinker -P pager=off -c "
+ select calls, round(total_exec_time::numeric) total_ms, round(mean_exec_time::numeric,2) mean_ms,
+ left(regexp_replace(query, '\s+', ' ', 'g'), 80) consulta
+ from pg_stat_statements order by total_exec_time desc limit 10";
+ # calls | total_ms | mean_ms | consulta
+ # 2249 | 1293 | 0.57 | INSERT INTO "agent_messages"(...
+ # 3836 | 956 | 0.25 | UPDATE "agent_messages" SET "recording_key" = ...
+ # 2249 | 433 | 0.19 | INSERT INTO "requests"(...
+```
+
+No dashboard do Thinker, a página do harness `wwz-generico` mostra requisições, tokens e custo em tempo real, e o painel do wwz mostra o lado do provedor.
+
+## 8 – O que eu medi e as lacunas
+
+Num servidor de 56 núcleos, com os limites do compose acima e o `wwz-fast` (latência zero):
+
+- **wwz-openai direto**: ~7.000 req/s com 20 clientes e 4 núcleos;
+- **Thinker, uma réplica**: **110 a 130 req/s**, não importa a concorrência (20, 100 ou 200), com ~7 ms por requisição quando não há fila; O `thinker-app` fica em ~1,2 a 1,5 CPU: o gargalo é o **event loop** de um processo Node só, não o banco (cada requisição gasta ~12 comandos SQL e ~5 ms no Postgres);
+- **Gravação de mensagens**: desligar (`thk routing recording set wwz-generico --enabled false`) sobe de ~112 para ~130 req/s;
+- **Modo buffered**: na rota padrão o TTFT é igual ao E2E (o Thinker entrega a resposta inteira). Para medir TTFT de verdade, use o virtual `wwz-stream` com o perfil `realista`.
+
+As lacunas que esse ambiente expõe:
+
+- **Um processo por container**: o Thinker não usa mais de um núcleo para o proxy. Escalar é subir réplicas (tutorial #2), e os guardrails (`THINKER_*_MAX`) valem **por processo**;
+- **Headers `x-wwz-*` não passam**: o Thinker não repassa headers arbitrários do cliente ao provedor (e está certo). `--tool-prob`, `--scenario` e `--seed` do `wwz-load` não chegam ao simulador; o comportamento tem que estar no YAML do wwz (`generation.tool_calls`, cenários casados por conteúdo);
+- **Descoberta antes da base URL**: o `provider connect` não aceita `--base-url`, então a primeira descoberta vai para o provedor real (seção 5.2);
+- **Fallback com rótulo de chave**: o CLI (`--models`, `--fallback`) só aceita nomes de modelo. Fixar o rótulo da chave num fallback só pela API REST (`PUT /api/v1/routing//tiers/default/fallbacks` com `routes[].keyLabel`);
+- **Chave repetida = mesma conexão**: conectar de novo com a mesma chave e outro rótulo devolve a conexão que já existe. Uma conta, uma chave;
+- **Hairpin NAT**: de dentro do servidor, o domínio público pode não alcançar o próprio Traefik; use os nomes da rede Docker ou `--add-host`.
+
+E os três bugs que esse ambiente encontrou, já corrigidos: redes Docker em `198.18.0.0/15` e `2001:db8::/32` vistas como públicas e o CLI quebrando no container `read_only` (**0.31.4**); o prefixo do catálogo indo para o OpenCode com base URL própria (**0.31.5**); e o `anthropic-beta` do Claude Code sumindo na conexão Anthropic com base URL própria (**0.31.6**).
+
+## 9 – Limpeza
+
+Para zerar o teste e começar de novo, execute:
+
+```Bash
+# zera os contadores do simulador
+docker run --rm --network network_public curlimages/curl:8.11.1 -s -X POST \
+ http://wwz-openai:9000/admin/reset -H "Authorization: Bearer $(grep ^WWZ_ADMIN_TOKEN= /opt/wwz-openai/.env | cut -d= -f2-)";
+
+# apaga relatorios antigos
+rm -f /opt/thinker-stress/reports/*.json;
+```
+
+Para remover tudo, inclusive o banco do Thinker:
+
+```Bash
+cd /opt/thinker && docker compose down -v;
+cd /opt/wwz-openai && docker compose down -v;
+```
+
+**CUIDADO**: o `down -v` apaga os volumes `thinker_pgdata` e `thinker_recordings`. Sem volta.
+
+Agora é contigo. Troque os perfis de latência e erro do wwz, aperte os limites das contas e descubra onde o seu Thinker dobra os joelhos. No tutorial #2 o stress fica sério: todos os provedores, todos os harnesses e um loop que não para.
+
+Terminamos por hoje!
+
+Patrick Brandão, patrickbrandao@gmail.com
+
+“Conhece o teu inimigo e conhece-te a ti mesmo;
em cem batalhas nunca correrás perigo.“
Sun Tzu
diff --git a/docs/tutoriais/thinker-with-white-wizard-02.md b/docs/tutoriais/thinker-with-white-wizard-02.md
new file mode 100644
index 00000000..12d2bc3a
--- /dev/null
+++ b/docs/tutoriais/thinker-with-white-wizard-02.md
@@ -0,0 +1,834 @@
+# Thinker com White Wizard #2 – Todos os provedores, todos os harnesses, stress sem fim
+
+Saudações. Nesse tutorial vou ensinar como estressar o **Thinker** com **todos** os provedores que o **White Wizard** simula, **dois harnesses de cada tipo** de cliente que o `wwz-load` sabe imitar e um **loop sem fim** que só para quando o servidor não tiver mais CPU, memória e disco para dar.
+
+Pré-requisitos:
+
+- O tutorial **#1** concluído (`thinker-with-white-wizard-01.md`): Thinker **0.31.6** ou mais novo em `/opt/thinker`, as imagens `white-wizard:local`, `white-wizard-panel:local` e `white-wizard-load:local`, o Traefik na `network_public` e os atalhos `thk`/`thki`;
+- DNS de `wwz-todos-panel.seudominio.com.br` apontando para o servidor;
+- Um servidor que você **aceita derrubar**. O loop da seção 8 não tem freio.
+
+**CRÍTICO**: não rode este teste num servidor que tem outros serviços de produção. Ele foi feito para esgotar recursos; o kernel vai começar a matar processo por falta de memória e o disco vai encher de gravações. Você foi avisado.
+
+## 1 – O que muda em relação ao #1
+
+No #1 era um provedor, um harness e uma réplica. Agora:
+
+- **4 instâncias do wwz**, cada uma com os **6 drivers** (OpenAI, Anthropic, OpenRouter, OpenCode, Gemini e Azure) e um comportamento:
+ - **wwz-perfeito**: sem erro e latência zero;
+ - **wwz-falhas**: **20%** de erros variados, inclusive no meio do stream;
+ - **wwz-lento**: latência de cauda pesada (p99 do TTFT em 20 s);
+ - **wwz-instavel**: 2% de erros e uma **janela de indisponibilidade** de 2 minutos a cada 10;
+- **6 provedores nativos** no Thinker, cada um com **4 conexões** (uma por instância);
+- **12 harnesses**, dois por tipo de cliente do `wwz-load`, com rota principal na instância de falhas e fallback em cascata;
+- **N réplicas** do `thinker-app` atrás de um alias de rede;
+- **Geradores eternos**: containers do `wwz-load` com `--restart unless-stopped`, adicionados em degraus.
+
+O desenho:
+
+```Text
+geradores (st-N-h-*) --> thinker-pool:2099 --> thinker-app, thinker-app-2 ... thinker-app-N
+ |
+ +-------------------------------+-------------------------------+
+ v v v v |
+ wwz-perfeito wwz-falhas wwz-lento wwz-instavel +--> thinker-postgres
+ /openai /anthropic /openrouter /opencode /gemini /azure (em cada uma)
+```
+
+## 2 – Preparando
+
+Crie o diretório do teste e confira que o Thinker do #1 está de pé:
+
+```Bash
+mkdir -p /opt/wwz-todos/run /opt/thinker-stress-02/reports;
+docker ps --filter name=thinker --format '{{.Names}} {{.Image}} {{.Status}}';
+ # thinker-app tmsoftbrasil/thinker:0.31.6 Up 2 hours (healthy)
+ # thinker-postgres postgres:18-alpine Up 2 hours (healthy)
+```
+
+**CUIDADO**: o Thinker reaproveita a conexão quando a **chave se repete**, mesmo com outro rótulo. Por isso cada instância do wwz tem a sua própria chave; com uma chave só, as quatro "conexões" viram uma e a última base URL vence.
+
+Gere as credenciais:
+
+```Bash
+cd /opt/wwz-todos;
+umask 077;
+( echo "DOMINIO=seudominio.com.br";
+ echo "WWZ_ADMIN_TOKEN=$(openssl rand -hex 24)";
+ echo "WWZ_PANEL_ADMIN_PASSWORD=$(openssl rand -hex 12)";
+ for n in 1 2 3 4; do echo "WWZ_KEY_$n=wwz-key-$(openssl rand -hex 12)"; done;
+) > .env;
+chmod 600 .env;
+```
+
+## 3 – White Wizard com todos os drivers
+
+#### 3.1 – Configuração
+
+Um YAML só serve as quatro instâncias. Os perfis de erro e de latência entram por `envsubst` na hora de renderizar. Três detalhes que custaram horas de investigação:
+
+- **OpenCode**: o Thinker descobre os modelos do OpenCode pelo **catálogo dele**, não pelo `/v1/models` da base configurada. O simulador precisa servir **os mesmos ids do catálogo real** (`glm-5.3`, `kimi-k3`, `big-pickle`), senão nada roteia;
+- **Gratuitos do OpenCode**: como no real, são **200 requisições por dia**. Para stress, `free_requests_per_day` vai às alturas;
+- **Claude**: o Claude Code pede `claude-sonnet-5-5` e companhia; esses ids existem só no driver `anthropic`.
+
+Crie `/opt/wwz-todos/todos.yaml`:
+
+```YAML
+# /opt/wwz-todos/todos.yaml
+# White Wizard com os seis drivers por prefixo. Modelo renderizado com envsubst:
+# ${WWZ_KEY_1..4} (chaves) e ${WWZ_ERROS}/${WWZ_LATENCIA} (perfil desta instancia).
+server:
+ listen: ":8000"
+ auth: keys
+ reuseport: true
+ concurrency: 1000000
+ trusted_proxies: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16]
+
+admin:
+ listen: ":9000"
+
+store:
+ event_stream: true
+
+usage:
+ extended: true
+
+drivers:
+ - {name: openai, prefix: /openai}
+ - {name: anthropic, prefix: /anthropic}
+ - {name: openrouter, prefix: /openrouter}
+ # gratuitos: 200 requisicoes por dia por padrao, como no real
+ - {name: opencode, prefix: /opencode, options: {free_requests_per_day: 1000000000}}
+ - {name: gemini, prefix: /gemini}
+ - {name: azure, prefix: /azure}
+
+accounts:
+ - {id: conta-stress}
+
+keys:
+ - {key: ${WWZ_KEY_1}, label: stress-1, account: conta-stress}
+ - {key: ${WWZ_KEY_2}, label: stress-2, account: conta-stress}
+ - {key: ${WWZ_KEY_3}, label: stress-3, account: conta-stress}
+ - {key: ${WWZ_KEY_4}, label: stress-4, account: conta-stress}
+
+profiles:
+ latency:
+ rapida: {ttft: 0ms, itl: 0ms}
+ realista: {queue: {dist: lognormal, median: 20ms, p99: 300ms}, ttft: {dist: normal, mean: 450ms, stddev: 120ms}, itl: {dist: normal, mean: 18ms, stddev: 4ms}}
+ cauda: {queue: {dist: lognormal, median: 50ms, p99: 8s}, ttft: {dist: lognormal, median: 800ms, p99: 20s}, itl: {dist: normal, mean: 30ms, stddev: 15ms}}
+ errors:
+ sem-erro: {rate: 0}
+ falhas-20: {rate: 20, types: [rate_limit, server_error, overloaded, bad_gateway, gateway_timeout, connection_reset], mid_stream_rate: 6, mid_stream_types: [stream_truncated, stream_error_event]}
+ instavel:
+ rate: 2
+ types: [server_error]
+ outages:
+ - {schedule: "0 */10 * * * *", duration: 2m, type: overloaded}
+
+models:
+ - {id: wwz-fast, owned_by: white-wizard, protocols: ["*"], context_window: 128000, capabilities: {tools: true, reasoning: true, json_schema: true}, latency: "${WWZ_LATENCIA}", errors: "${WWZ_ERROS}", generation: default}
+ - {id: wwz-realistic, owned_by: white-wizard, protocols: ["*"], context_window: 128000, capabilities: {tools: true, reasoning: true}, latency: realista, errors: "${WWZ_ERROS}", generation: default}
+ # ids que os clientes Anthropic e o Claude Code pedem
+ - {id: claude-sonnet-5-5, owned_by: anthropic, drivers: [anthropic], protocols: [anthropic], context_window: 1000000, max_output: 128000, capabilities: {tools: true, reasoning: true, json_schema: true}, pricing: {input_per_m: 2, output_per_m: 10}, latency: "${WWZ_LATENCIA}", errors: "${WWZ_ERROS}", generation: default}
+ - {id: claude-haiku-5-5, owned_by: anthropic, drivers: [anthropic], protocols: [anthropic], context_window: 1000000, max_output: 128000, capabilities: {tools: true, reasoning: true, json_schema: true}, pricing: {input_per_m: 0.1, output_per_m: 0.5}, latency: "${WWZ_LATENCIA}", errors: "${WWZ_ERROS}", generation: default}
+ # ids do catalogo real do OpenCode: o Thinker descobre os modelos do OpenCode
+ # pelo catalogo dele, nao pelo /v1/models da base configurada
+ - {id: glm-5.3, owned_by: zhipu, drivers: [opencode], protocols: [openai], catalogs: {opencode-go: {}}, latency: "${WWZ_LATENCIA}", errors: "${WWZ_ERROS}", generation: default}
+ - {id: kimi-k3, owned_by: moonshot, drivers: [opencode], protocols: [openai], catalogs: {opencode-go: {}}, latency: "${WWZ_LATENCIA}", errors: "${WWZ_ERROS}", generation: default}
+ - {id: big-pickle, owned_by: opencode, drivers: [opencode], protocols: [openai], catalogs: {opencode: {pricing: {input_per_m: 0, output_per_m: 0}}}, latency: "${WWZ_LATENCIA}", errors: "${WWZ_ERROS}", generation: default}
+```
+
+Renderize as quatro instâncias e valide cada uma:
+
+```Bash
+cd /opt/wwz-todos;
+set -a; . ./.env; set +a;
+for inst in perfeito:sem-erro:rapida falhas:falhas-20:realista lento:sem-erro:cauda instavel:instavel:rapida; do
+ n=${inst%%:*}; r=${inst#*:};
+ WWZ_ERROS=${r%%:*} WWZ_LATENCIA=${r#*:} \
+ envsubst '${WWZ_KEY_1} ${WWZ_KEY_2} ${WWZ_KEY_3} ${WWZ_KEY_4} ${WWZ_ERROS} ${WWZ_LATENCIA}' \
+ < todos.yaml > run/$n.yaml;
+ chmod 644 run/$n.yaml;
+ docker run --rm -v $PWD/run/$n.yaml:/w.yaml:ro white-wizard:local validate -config /w.yaml | head -1;
+done;
+ # ok config /w.yaml (7 models, 6 drivers, 4 keys)
+ # ok config /w.yaml (7 models, 6 drivers, 4 keys)
+ # ok config /w.yaml (7 models, 6 drivers, 4 keys)
+ # ok config /w.yaml (7 models, 6 drivers, 4 keys)
+```
+
+#### 3.2 – Compose
+
+Só a instância **perfeito** grava no Valkey (é ela que o painel acompanha); as outras guardam contadores em memória, cada uma isolada. Crie `/opt/wwz-todos/docker-compose.yml`:
+
+```YAML
+# /opt/wwz-todos/docker-compose.yml
+# White Wizard com os seis drivers em quatro instancias de comportamento
+# diferente (perfeito, falhas, lento, instavel), Valkey e painel admin.
+name: wwz-todos
+
+x-wwz: &wwz
+ image: white-wizard:local
+ restart: unless-stopped
+ read_only: true
+ tmpfs: [/run, /tmp]
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ cpus: ${WWZ_CPUS:-4}
+ mem_limit: ${WWZ_MEM:-8g}
+ ulimits:
+ nofile: {soft: 1048576, hard: 1048576}
+ healthcheck:
+ test: ["CMD", "/usr/local/bin/healthcheck", "http://127.0.0.1:9000/health/ready"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ networks: [interna, network_public]
+
+services:
+ wwz-perfeito:
+ <<: *wwz
+ container_name: wwz-perfeito
+ environment:
+ WWZ_CONFIG: /etc/wwz/wwz.yaml
+ WWZ_ADMIN_TOKEN: ${WWZ_ADMIN_TOKEN}
+ WWZ_VALKEY_ADDR: wwz-todos-valkey:6379
+ WWZ_LOG_LEVEL: warn
+ GOGC: "200"
+ volumes: ["./run/perfeito.yaml:/etc/wwz/wwz.yaml:ro"]
+ depends_on:
+ wwz-todos-valkey: {condition: service_healthy}
+
+ wwz-falhas:
+ <<: *wwz
+ container_name: wwz-falhas
+ environment: &memoria
+ WWZ_CONFIG: /etc/wwz/wwz.yaml
+ WWZ_ADMIN_TOKEN: ${WWZ_ADMIN_TOKEN}
+ WWZ_STORE: memory
+ WWZ_LOG_LEVEL: warn
+ GOGC: "200"
+ volumes: ["./run/falhas.yaml:/etc/wwz/wwz.yaml:ro"]
+
+ wwz-lento:
+ <<: *wwz
+ container_name: wwz-lento
+ environment: *memoria
+ volumes: ["./run/lento.yaml:/etc/wwz/wwz.yaml:ro"]
+
+ wwz-instavel:
+ <<: *wwz
+ container_name: wwz-instavel
+ environment: *memoria
+ volumes: ["./run/instavel.yaml:/etc/wwz/wwz.yaml:ro"]
+
+ wwz-todos-valkey:
+ image: valkey/valkey:8.1-alpine
+ container_name: wwz-todos-valkey
+ restart: unless-stopped
+ user: "999:999"
+ command: ["valkey-server", "--save", "", "--appendonly", "no", "--maxmemory", "2gb"]
+ tmpfs: [/run, /data]
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ healthcheck:
+ test: ["CMD", "valkey-cli", "ping"]
+ interval: 5s
+ timeout: 2s
+ retries: 10
+ networks: [interna]
+
+ wwz-todos-panel:
+ image: white-wizard-panel:local
+ container_name: wwz-todos-panel
+ restart: unless-stopped
+ security_opt: ["no-new-privileges:true"]
+ cap_drop: [ALL]
+ environment:
+ WWZ_PANEL_LISTEN: ":8100"
+ WWZ_PANEL_DB: /data/panel.db
+ WWZ_PANEL_PUBLIC_URL: https://wwz-todos-panel.${DOMINIO}
+ WWZ_PANEL_VALKEY_ADDR: wwz-todos-valkey:6379
+ WWZ_PANEL_WWZ_ADMIN_URL: http://wwz-perfeito:9000
+ WWZ_ADMIN_TOKEN: ${WWZ_ADMIN_TOKEN}
+ WWZ_PANEL_ADMIN_USER: admin
+ WWZ_PANEL_ADMIN_PASSWORD: ${WWZ_PANEL_ADMIN_PASSWORD}
+ volumes: [panel-data:/data]
+ depends_on:
+ wwz-perfeito: {condition: service_healthy}
+ healthcheck:
+ test: ["CMD", "/usr/local/bin/healthcheck", "http://127.0.0.1:8100/health/ready"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ networks: [interna, network_public]
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: network_public
+ traefik.http.routers.wwz-todos-panel.entrypoints: web,websecure
+ traefik.http.routers.wwz-todos-panel.rule: Host(`wwz-todos-panel.${DOMINIO}`)
+ traefik.http.routers.wwz-todos-panel.tls: "true"
+ traefik.http.routers.wwz-todos-panel.tls.certresolver: letsencrypt
+ traefik.http.routers.wwz-todos-panel.service: wwz-todos-panel
+ traefik.http.services.wwz-todos-panel.loadbalancer.server.port: "8100"
+
+volumes:
+ panel-data: {name: wwz-todos-panel-data}
+
+networks:
+ interna:
+ name: wwz-todos-interna
+ network_public:
+ external: true
+ name: network_public
+```
+
+Para subir e conferir cada driver numa instância, execute:
+
+```Bash
+cd /opt/wwz-todos; . ./.env;
+docker compose up -d --wait;
+
+C="docker run --rm --network network_public curlimages/curl:8.11.1 -s";
+W=http://wwz-perfeito:8000;
+$C $W/openai/v1/models -H "Authorization: Bearer $WWZ_KEY_1" | jq -r '[.data[].id] | join(" ")';
+ # wwz-fast wwz-realistic
+$C $W/anthropic/v1/models -H "x-api-key: $WWZ_KEY_1" -H 'anthropic-version: 2023-06-01' | jq -r '[.data[].id] | join(" ")';
+ # wwz-fast wwz-realistic claude-sonnet-5-5 claude-haiku-5-5
+$C $W/openrouter/api/v1/models -H "Authorization: Bearer $WWZ_KEY_1" | jq -r '[.data[].id] | join(" ")';
+ # white-wizard/wwz-fast white-wizard/wwz-realistic
+$C $W/opencode/inference/go/v1/models -H "Authorization: Bearer $WWZ_KEY_1" | jq -r '[.data[].id] | join(" ")';
+ # wwz-fast wwz-realistic glm-5.3 kimi-k3
+$C "$W/gemini/v1beta/models?key=$WWZ_KEY_1" | jq -r '[.models[].name] | join(" ")';
+ # models/wwz-fast models/wwz-realistic
+$C $W/azure/openai/v1/models -H "api-key: $WWZ_KEY_1" | jq -r '[.data[].id] | join(" ")';
+ # wwz-fast wwz-realistic
+```
+
+O painel fica em `https://wwz-todos-panel.seudominio.com.br` (usuário `admin`, senha no `.env`).
+
+## 4 – Os provedores no Thinker
+
+Cada driver do wwz vira um **provedor nativo** do Thinker com a base URL da conexão trocada (o mesmo truque do #1):
+
+| Driver do wwz | Provedor no Thinker | Base URL da conexão |
+| --- | --- | --- |
+| `openai` | `openai` | `http://wwz-:8000/openai` |
+| `anthropic` | `anthropic` | `http://wwz-:8000/anthropic` |
+| `openrouter` | `openrouter` | `http://wwz-:8000/openrouter` |
+| `gemini` | `gemini` | `http://wwz-:8000/gemini` |
+| `opencode` (Go) | `opencode-go` | `http://wwz-:8000/opencode/inference/go/openai` |
+| `opencode` (Zen) | `opencode` | `http://wwz-:8000/opencode/inference/openai` |
+| `azure` | provedor **custom** OpenAI | `http://wwz-:8000/azure/openai/v1` |
+
+O Thinker não tem provedor Azure nativo. O endpoint `/openai/v1` do Azure aceita `Authorization: Bearer`, então um provedor custom do tipo OpenAI resolve (`thk provider custom add --name wwz-azure --endpoint http://wwz-perfeito:8000/azure/openai/v1 --api openai --credential-stdin`). Ele fica fora do provisionamento automático para não poluir os fallbacks.
+
+## 5 – Os 12 harnesses
+
+O `wwz-load` imita seis tipos de cliente. Cada tipo ganha duas plataformas do Thinker que falam a mesma superfície:
+
+| Cliente do wwz-load | Plataforma | Harnesses | Rota principal |
+| --- | --- | --- | --- |
+| `run` (SDK OpenAI, Chat Completions) | OpenAI SDK | `h-openai-sdk-1`, `-2` | `openai` / `wwz-fast`, `wwz-realistic` |
+| `run --protocol anthropic` (SDK Anthropic, Messages) | Anthropic SDK | `h-anthropic-sdk-1`, `-2` | `anthropic` / `claude-haiku-5-5`, `claude-sonnet-5-5` |
+| `agents --profile coder` (agente de código com ferramentas) | OpenCode | `h-opencode-1`, `-2` | `opencode-go` / `glm-5.3`, `kimi-k3` |
+| `agents --profile pesquisa` (busca e fetch) | Hermes Agent | `h-hermes-1`, `-2` | `openrouter` / `white-wizard/wwz-fast`, `wwz-realistic` |
+| `agents --profile chat` (histórico longo) | Open WebUI | `h-open-webui-1`, `-2` | `gemini` / `wwz-fast`; `opencode` / `big-pickle` |
+| `claude-code` (Claude Code 2.1.308 byte a byte) | Claude Code | `h-claude-code-1`, `-2` | `anthropic` / `claude-sonnet-5-5`, `claude-haiku-5-5` |
+
+Toda rota tem a mesma cascata: principal na **falhas**, fallback na **instavel** e por fim na **perfeito**. Cada harness exercita o failover do Thinker o tempo todo.
+
+**CUIDADO**: o CLI (`--models`, `--fallback`) não fixa o **rótulo da chave** num fallback, só o nome do modelo, e uma rota com rótulo que não existe cai em qualquer conexão do provedor sem aviso. O script usa a API REST (`routes[].keyLabel`) com uma chave de API admin.
+
+## 6 – Provisionando
+
+O script cria os harnesses, as 24 conexões, as rotas com a cascata e guarda as chaves dos harnesses num arquivo `600`. É idempotente: rode de novo depois de mudar qualquer coisa.
+
+Crie `/opt/thinker-stress-02/provisionar.sh`:
+
+```Bash
+#!/bin/bash
+# /opt/thinker-stress-02/provisionar.sh
+# Provisiona no Thinker: provedores apontando para as instancias do wwz,
+# 12 harnesses (2 por tipo de cliente do wwz-load) e as rotas de cada um.
+# Idempotente: pode rodar de novo depois de mudar algo.
+set -euo pipefail
+cd "$(dirname "$0")"
+. ./.env
+
+T="docker exec -e XDG_CONFIG_HOME=/tmp/thinker-cli thinker-app thinker"
+TI="docker exec -i -e XDG_CONFIG_HOME=/tmp/thinker-cli thinker-app thinker"
+
+# conecta : cria a conexao (se faltar) e
+# aponta a base. Uma chave por instancia: o Thinker reaproveita a conexao
+# quando a chave se repete, mesmo com outro rotulo.
+conecta() {
+ local p=$1 rotulo=$2 k=$3 base=$4 id
+ id=$($T provider list /dev/null
+ echo "conexao $p/$rotulo -> $base";
+}
+
+# 1 - harnesses: nome plataforma categoria
+while read -r nome plat cat; do
+ $T agent create --name "$nome" --platform "$plat" --category "$cat" --if-absent /dev/null
+ echo "harness $nome ($plat)";
+done <<'EOF'
+h-openai-sdk-1 openai-sdk app
+h-openai-sdk-2 openai-sdk app
+h-anthropic-sdk-1 anthropic-sdk app
+h-anthropic-sdk-2 anthropic-sdk app
+h-opencode-1 opencode coding
+h-opencode-2 opencode coding
+h-hermes-1 hermes personal
+h-hermes-2 hermes personal
+h-open-webui-1 open-webui automation
+h-open-webui-2 open-webui automation
+h-claude-code-1 claude-code coding
+h-claude-code-2 claude-code coding
+EOF
+
+# 2 - provedores: uma conexao por instancia do wwz (rotulo = instancia)
+n=0
+for inst in perfeito falhas lento instavel; do
+ n=$((n + 1)); k=$(grep "^WWZ_KEY_$n=" .env | cut -d= -f2-)
+ W=http://wwz-$inst:8000
+ conecta openai "$inst" "$k" "$W/openai"
+ conecta anthropic "$inst" "$k" "$W/anthropic"
+ conecta openrouter "$inst" "$k" "$W/openrouter"
+ conecta gemini "$inst" "$k" "$W/gemini"
+ conecta opencode-go "$inst" "$k" "$W/opencode/inference/go/openai"
+ conecta opencode "$inst" "$k" "$W/opencode/inference/openai"
+done
+$T provider refresh /dev/null
+
+# 3 - habilita todos os provedores em todos os harnesses
+harnesses=$($T agent list /dev/null 2>&1 || true
+ done
+ done
+done
+
+# 4 - rota padrao: . Principal na instancia
+# "falhas"; fallbacks em "instavel" e "perfeito" (cada harness exercita o
+# failover). O CLI nao fixa o rotulo da chave nos fallbacks; a API REST fixa.
+if [ -z "${THINKER_API_KEY:-}" ]; then
+ k=$($T key create --name stress-02 > .env
+ THINKER_API_KEY=$k
+fi
+api() {
+ docker run --rm -i --network network_public curlimages/curl:8.11.1 -s -f -X "$1" \
+ "http://thinker-app:2099/api/v1$2" -H "X-API-Key: $THINKER_API_KEY" \
+ -H 'content-type: application/json' --data-binary @-
+}
+while read -r h p m; do
+ $T agent configure "$h" --models "$m" --provider "$p" --key-label falhas --force /dev/null
+ jq -n --arg p "$p" --arg m "$m" '{models: [$m, $m], routes: [
+ {provider: $p, authType: "api_key", model: $m, keyLabel: "instavel"},
+ {provider: $p, authType: "api_key", model: $m, keyLabel: "perfeito"}]}' \
+ | api PUT "/routing/$h/tiers/default/fallbacks" >/dev/null
+ echo "rota $h -> $p/$m (falhas -> instavel -> perfeito)";
+done <<'EOF'
+h-openai-sdk-1 openai wwz-fast
+h-openai-sdk-2 openai wwz-realistic
+h-anthropic-sdk-1 anthropic claude-haiku-5-5
+h-anthropic-sdk-2 anthropic claude-sonnet-5-5
+h-opencode-1 opencode-go opencode-go/glm-5.3
+h-opencode-2 opencode-go opencode-go/kimi-k3
+h-hermes-1 openrouter white-wizard/wwz-fast
+h-hermes-2 openrouter white-wizard/wwz-realistic
+h-open-webui-1 gemini wwz-fast
+h-open-webui-2 opencode opencode/big-pickle
+h-claude-code-1 anthropic claude-sonnet-5-5
+h-claude-code-2 anthropic claude-haiku-5-5
+EOF
+
+# 5 - chaves dos harnesses num arquivo 600 (lido pelo loop)
+umask 077
+: > harnesses.keys
+for h in $harnesses; do
+ printf '%s=%s\n' "$h" "$($T agent key show "$h" --raw > harnesses.keys
+done
+echo "chaves em $(pwd)/harnesses.keys";
+```
+
+O script lê as chaves do wwz do `.env` do próprio diretório. Junte os dois `.env` e rode:
+
+```Bash
+cd /opt/thinker-stress-02;
+umask 077;
+( grep -E '^(WWZ_KEY_[1-4]|WWZ_ADMIN_TOKEN)=' /opt/wwz-todos/.env;
+ echo "WWZ_LOAD_IMAGE=white-wizard-load:local";
+) > .env;
+chmod 600 .env;
+chmod +x provisionar.sh;
+./provisionar.sh | tail -3;
+ # rota h-claude-code-1 -> anthropic/claude-sonnet-5-5 (falhas -> instavel -> perfeito)
+ # rota h-claude-code-2 -> anthropic/claude-haiku-5-5 (falhas -> instavel -> perfeito)
+ # chaves em /opt/thinker-stress-02/harnesses.keys
+```
+
+Para provar que os 12 respondem, mande uma requisição real em cada um:
+
+```Bash
+for h in $(thk agent list | jq -r '.agents[].agent_name | select(startswith("h-"))'); do
+ printf '%-18s ' $h; thk routing test $h "diga ok" docker-compose.replicas.yml
+
+# replicas que sobraram de uma escala maior
+for c in $(docker ps -a --format '{{.Names}}' | grep -E '^thinker-app-[0-9]+$' || true); do
+ if [ "${c#thinker-app-}" -gt "$N" ]; then docker rm -f "$c" >/dev/null; echo "removida $c"; fi;
+done
+
+DB_POOL_MAX=$POOL docker compose -f docker-compose.yml -f docker-compose.replicas.yml up -d --wait
+docker ps --filter name=thinker-app --format '{{.Names}} {{.Status}}' | sort -V
+```
+
+Para subir 3 réplicas e medir, execute:
+
+```Bash
+chmod +x /opt/thinker/escala.sh;
+/opt/thinker/escala.sh 3;
+ # thinker-app Up 24 seconds (healthy)
+ # thinker-app-2 Up 19 seconds (healthy)
+ # thinker-app-3 Up 18 seconds (healthy)
+
+# o alias responde com os tres IPs
+docker run --rm --network network_public alpine:3.20 getent ahosts thinker-pool | awk '{print $1}' | sort -u | grep -v :;
+ # 172.20.0.13
+ # 172.20.0.14
+ # 172.20.0.7
+
+# o mesmo stress do #1, agora contra o pool
+THINKER=http://thinker-pool:2099 DUR=25s /opt/thinker-stress/stress.sh conc 300;
+ # {"requests":8460,"ok":8460,"errors":0,"rps":338.4,"ttft_p50":53.375,...}
+```
+
+Três réplicas, **338 req/s**: 2,75 vezes uma só, com cada `thinker-app` em ~1,3 CPU. A escala é quase linear até o Postgres virar o gargalo.
+
+**CRÍTICO**: cada réplica abre até `DB_POOL_MAX + AUTH_DB_POOL_MAX` conexões no Postgres. Com 40 + 10 por réplica, 7 réplicas já passam de 350. Antes de subir mais, aumente o `max_connections` do `thinker-postgres` (no compose do #1) ou baixe o `DB_POOL_POR_REPLICA`.
+
+## 8 – Stress sem fim
+
+#### 8.1 – O loop
+
+O `loop.sh` sobe um **lote** de 12 geradores (um por harness, cada um no seu tipo de cliente) a cada `DEGRAU` segundos. Os geradores rodam para sempre e reiniciam sozinhos. Depois de `MAX_DEGRAUS` (por padrão, um milhão) ele só segura a carga e registra amostras.
+
+Crie `/opt/thinker-stress-02/loop.sh`:
+
+```Bash
+#!/bin/bash
+# /opt/thinker-stress-02/loop.sh
+# Stress sem fim: a cada degrau sobe mais um lote de geradores (um por
+# harness), que rodam para sempre (--restart unless-stopped), ate esgotar
+# CPU, memoria e I/O do servidor. Nunca para sozinho.
+# Uso:
+# ./loop.sh iniciar sobe degraus ate MAX_DEGRAUS e segura a carga
+# ./loop.sh status geradores, consumo e ultimo degrau
+# ./loop.sh parar remove todos os geradores
+set -euo pipefail
+cd "$(dirname "$0")"
+. ./.env
+
+IMG=${WWZ_LOAD_IMAGE:-white-wizard-load:local}
+NET=${NET:-network_public}
+ALVO=${ALVO:-http://thinker-pool:2099} # alias comum a todas as replicas
+CONC=${CONC:-50} # concorrencia de cada gerador
+AGENTES=${AGENTES:-25} # agentes de cada gerador agents
+DEGRAU=${DEGRAU:-120} # segundos entre degraus
+MAX_DEGRAUS=${MAX_DEGRAUS:-1000000} # na pratica, sem fim
+ROTULO=thinker.stress=loop
+
+chave() { grep "^$1=" harnesses.keys | cut -d= -f2-; }
+
+# gera
+gera() {
+ local nome=$1 h=$2; shift 2
+ docker run -d --name "$nome" --label "$ROTULO" --restart unless-stopped \
+ --network "$NET" --ulimit nofile=1048576:1048576 \
+ --log-driver local --log-opt max-size=10m \
+ -e WWZ_LOAD_KEY="$(chave "$h")" "$IMG" wwz-load "$@" >/dev/null
+}
+
+# um lote: um gerador por harness, cada um no seu tipo de cliente
+lote() {
+ local n=$1 h
+ for h in h-openai-sdk-1 h-openai-sdk-2; do
+ gera "st-$n-$h" "$h" run --target "$ALVO" --model auto --concurrency "$CONC" --duration 0 --progress 30s --timeout 300s
+ done
+ for h in h-anthropic-sdk-1 h-anthropic-sdk-2; do
+ gera "st-$n-$h" "$h" run --target "$ALVO" --protocol anthropic --model auto --concurrency "$CONC" --duration 0 --progress 30s --timeout 300s
+ done
+ for h in h-opencode-1 h-opencode-2; do
+ gera "st-$n-$h" "$h" agents --target "$ALVO" --model auto --profile coder --agents "$AGENTES" --turns 8 --duration 876000h --progress 30s
+ done
+ for h in h-hermes-1 h-hermes-2; do
+ gera "st-$n-$h" "$h" agents --target "$ALVO" --model auto --profile pesquisa --agents "$AGENTES" --turns 6 --duration 876000h --progress 30s
+ done
+ for h in h-open-webui-1 h-open-webui-2; do
+ gera "st-$n-$h" "$h" agents --target "$ALVO" --model auto --profile chat --agents "$AGENTES" --turns 20 --duration 876000h --progress 30s
+ done
+ for h in h-claude-code-1 h-claude-code-2; do
+ gera "st-$n-$h" "$h" claude-code run --target "$ALVO" --auth api-key --key "$(chave "$h")" --model claude-sonnet-5-5 --concurrency "$CONC" --turns 3 --duration 876000h
+ done
+}
+
+amostra() {
+ local n=$1
+ printf '%s degrau=%s geradores=%s ' "$(date -u +%FT%TZ)" "$n" "$(docker ps -q --filter label=$ROTULO | wc -l)"
+ docker stats --no-stream --format '{{.Name}}={{.CPUPerc}}/{{.MemUsage}}' \
+ $(docker ps --format '{{.Names}}' | grep -E '^(thinker-|wwz-(perfeito|falhas|lento|instavel)$)') | tr '\n' ' '
+ echo
+}
+
+case "${1:-}" in
+ iniciar)
+ if [ -f reports/loop.pid ] && kill -0 "$(cat reports/loop.pid)" 2>/dev/null; then
+ echo "ja existe um loop rodando (./loop.sh parar)"; exit 1;
+ fi
+ mkdir -p reports
+ echo $$ > reports/loop.pid
+ n=$(docker ps -a --filter label=$ROTULO --format '{{.Names}}' | sed -n 's/^st-\([0-9]*\)-.*/\1/p' | sort -n | tail -1)
+ n=${n:-0}
+ while [ "$n" -lt "$MAX_DEGRAUS" ]; do
+ n=$((n + 1))
+ lote "$n"
+ amostra "$n" | tee -a reports/loop.log
+ sleep "$DEGRAU"
+ done
+ while true; do amostra "$n" | tee -a reports/loop.log; sleep "$DEGRAU"; done
+ ;;
+ status)
+ docker ps --filter label=$ROTULO --format '{{.Names}} {{.Status}}' | sort -V | tail -20
+ tail -5 reports/loop.log 2>/dev/null || true
+ ;;
+ parar)
+ [ -f reports/loop.pid ] && kill "$(cat reports/loop.pid)" 2>/dev/null || true
+ rm -f reports/loop.pid
+ docker ps -aq --filter label=$ROTULO | xargs -r docker rm -f >/dev/null
+ echo "geradores removidos";
+ ;;
+ *) sed -n '/^# Stress sem fim/,/^set -euo/p' "$0" | sed '$d'; exit 2 ;;
+esac
+```
+
+Para começar, solto do terminal (sobrevive ao logout), execute:
+
+```Bash
+cd /opt/thinker-stress-02;
+chmod +x loop.sh;
+setsid nohup ./loop.sh iniciar > reports/loop.out 2>&1 < /dev/null &
+
+# um segundo loop e recusado (o PID fica em reports/loop.pid)
+./loop.sh iniciar;
+ # ja existe um loop rodando (./loop.sh parar)
+
+# acompanhar
+./loop.sh status;
+tail -f reports/loop.log;
+```
+
+Os botões de ajuste, todos por variável de ambiente:
+
+- **`CONC`**: concorrência de cada gerador `run` e `claude-code` (padrão 50);
+- **`AGENTES`**: agentes de cada gerador `agents` (padrão 25);
+- **`DEGRAU`**: segundos entre lotes (padrão 120);
+- **`MAX_DEGRAUS`**: quantos lotes subir antes de só segurar (padrão: sem fim);
+- **`ALVO`**: para onde mandar (padrão `http://thinker-pool:2099`; use `https://thinker.seudominio.com.br` com `--add-host` para incluir o Traefik).
+
+Cada lote acrescenta 4 × 50 conexões de SDK, 2 × 50 sessões de Claude Code e 6 × 25 agentes com ferramentas. Em uma hora, com o degrau padrão, são 30 lotes: 9.000 clientes simultâneos.
+
+Para parar tudo (o processo do loop e os geradores; sem matar o loop, ele subiria o próximo lote no degrau seguinte):
+
+```Bash
+/opt/thinker-stress-02/loop.sh parar;
+ # geradores removidos
+```
+
+#### 8.2 – O que eu vi em dois degraus
+
+Com 3 réplicas, `CONC=20`, `AGENTES=10` e dois lotes (24 geradores), em cerca de três minutos:
+
+- **CPU**: as três réplicas presas em 1,1 a 1,4 CPU cada (o teto de um processo Node) e o Postgres em 1,6 CPU. Daqui para cima só com mais réplicas;
+- **Failover**: a instância **falhas** recebeu 4.801 requisições e injetou 1.310 erros. Depois dos primeiros 429 o Thinker põe a rota em **cooldown** e manda direto para o fallback, sem nem tentar: a **instavel** recebeu 29.466 e a **perfeito** 594 (o segundo fallback, quando a instável falha);
+- **Cliente**: o gerador `run` do `h-openai-sdk-1` fechou 3.499 requisições em 2 min 30 s com **7 erros**; os agentes `coder` do OpenCode, 77 sessões sem nenhuma falha;
+- **A exceção**: os harnesses **Claude Code** receberam o 429 do cooldown em cerca de **80%** das requisições, sem descer a cascata. Veja a seção 10.
+
+Para ver o mesmo no seu servidor, rode as consultas da seção 9 com o loop ligado.
+
+#### 8.3 – Tirando os freios
+
+Os limites do compose do #1 existem para proteger o resto do servidor. Para esgotar **tudo**, tire-os num override, sem mexer no arquivo original:
+
+```YAML
+# /opt/thinker/docker-compose.override.yml
+# sem limite de CPU e memoria, banco com fsync de verdade (I/O)
+services:
+ thinker-app:
+ cpus: 0
+ mem_limit: 0
+ environment:
+ NODE_OPTIONS: --max-old-space-size=32768
+ thinker-postgres:
+ cpus: 0
+ mem_limit: 0
+ command: [postgres, -c, max_connections=2000, -c, shared_buffers=16GB, -c, synchronous_commit=on,
+ -c, shared_preload_libraries=pg_stat_statements, -c, track_io_timing=on]
+```
+
+Os três recursos e o que consome cada um:
+
+- **CPU**: réplicas do Thinker (`escala.sh` com uma por par de núcleos) e geradores;
+- **Memória**: streams abertos (`wwz-lento` segura conexões por até 20 s), histórico dos agentes `chat` (20 turnos) e o `max-old-space-size` de cada réplica;
+- **I/O**: cada requisição grava ~12 comandos no Postgres e um arquivo de gravação no volume `thinker_recordings`. Com `synchronous_commit=on`, todo commit espera o disco.
+
+Aplique o override e as réplicas:
+
+```Bash
+cd /opt/thinker;
+docker compose up -d --wait;
+/opt/thinker/escala.sh $(( $(nproc) / 4 ));
+```
+
+O host também tem limites. Confira antes de culpar o Thinker:
+
+```Bash
+# arquivos abertos, fila de conexoes e portas efemeras
+sysctl fs.file-max net.core.somaxconn net.ipv4.ip_local_port_range;
+ # fs.file-max = 9223372036854775807
+ # net.core.somaxconn = 4096
+ # net.ipv4.ip_local_port_range = 32768 60999
+```
+
+Com dezenas de milhares de conexões, o `ip_local_port_range` padrão (28 mil portas por destino) acaba antes da CPU. Ajuste por sua conta e risco: é configuração do sistema.
+
+## 9 – Lendo os resultados
+
+O `wwz-load` sem `--duration` só imprime o relatório quando para. Com o loop, os números vêm de três lugares.
+
+Para ver os erros e os fallbacks por harness, rótulo da chave e posição na cascata (últimos 5 minutos), execute:
+
+```Bash
+docker exec thinker-postgres psql -U thinker -d thinker -c "
+ select agent_name, provider_key_label rotulo, coalesce(fallback_index::text, '-') fb,
+ status, count(*)
+ from agent_messages
+ where timestamp > now() - interval '5 min' and agent_name like 'h-%'
+ group by 1, 2, 3, 4 order by 1, 5 desc";
+```
+
+Para ver o que cada instância do wwz recebeu e injetou, execute:
+
+```Bash
+cd /opt/wwz-todos; . ./.env;
+for i in perfeito falhas lento instavel; do
+ printf '%-9s ' $i;
+ docker run --rm --network network_public curlimages/curl:8.11.1 -s http://wwz-$i:9000/admin/stats \
+ -H "Authorization: Bearer $WWZ_ADMIN_TOKEN" | jq -c '.totals | {requests, errors_injected, rate_limited}';
+done;
+```
+
+Para o consumo do servidor, a linha do `reports/loop.log` traz CPU e memória de cada container do teste a cada degrau. Junte com `vmstat 5` e `iostat -x 5` (pacote `sysstat`) para ver memória livre e fila de disco.
+
+## 10 – Lacunas que esse ambiente expôs
+
+Construir esse ambiente revelou três bugs no Thinker, já corrigidos:
+
+- **0.31.4**: redes Docker em `198.18.0.0/15` (RFC 2544) e `2001:db8::/32` (RFC 3849) eram tratadas como **públicas**, e `http://wwz-openai:8000` era recusado; e o CLI dentro do container `read_only` falhava depois de criar o harness, ao gravar o cache da chave;
+- **0.31.5**: com base URL própria, o OpenCode recebia o modelo **com o prefixo do catálogo** (`opencode-go/glm-5.3`) e respondia `Model is unavailable`;
+- **0.31.6**: a conexão Anthropic com base URL própria não recebia o **`anthropic-beta`** do cliente, e todo turno do Claude Code voltava 400 (`messages.1.output_config: Extra inputs are not permitted`).
+
+E as lacunas que continuam abertas (contorne como mostrado):
+
+- **Uma réplica = um núcleo**: ~125 req/s por processo; escala horizontal só com réplicas, e os guardrails `THINKER_*_MAX` valem por processo;
+- **Descoberta do OpenCode**: vem do catálogo, ignora a base URL da conexão;
+- **Chave repetida = mesma conexão**: o rótulo novo é ignorado;
+- **Fallback sem rótulo pelo CLI**, e rótulo inexistente cai em qualquer conexão;
+- **Desconectar só desativa**: a conexão fica `is_active=false` e volta ao reconectar com a mesma chave;
+- **Sem Azure nativo**: provedor custom OpenAI;
+- **`provider connect` sem `--base-url`**: a primeira validação vai ao provedor real;
+- **Headers `x-wwz-*` não passam**: o comportamento do simulador fica todo no YAML;
+- **Cooldown sem cascata no Claude Code** (aberta, sem correção): com o principal em cooldown, os harnesses Claude Code receberam o 429 local (`Provider route temporarily cooling down after an upstream 429`) em vez de seguir para os fallbacks, enquanto o Anthropic SDK, com a mesma rota, desceu a cascata. Isolar se é a plataforma, a forma da requisição ou a janela de indisponibilidade da **instavel** fica para uma investigação própria.
+
+## 11 – Limpeza
+
+Para desmontar o teste #2 e voltar ao ambiente do #1, execute:
+
+```Bash
+# geradores
+/opt/thinker-stress-02/loop.sh parar;
+
+# volta a uma replica e remove o override
+/opt/thinker/escala.sh 1;
+rm -f /opt/thinker/docker-compose.override.yml;
+cd /opt/thinker && docker compose up -d --wait;
+
+# harnesses do teste (as conexoes ficam inativas depois que nenhuma rota as usa)
+for h in $(thk agent list | jq -r '.agents[].agent_name | select(startswith("h-"))'); do
+ thk agent delete $h --yes “O que não provoca minha morte
faz com que eu fique mais forte.“
Friedrich Nietzsche
From ba1f3f42fc42230726197c79443e5f0d3d01fde2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patrick=20Brand=C3=A3o?=
Date: Sat, 7 Nov 2026 17:50:05 -0300
Subject: [PATCH 2/2] chore(release): version 0.31.7
---
docs/releases/0.31.7.md | 9 +++++++++
package-lock.json | 2 +-
packages/thinker/package.json | 2 +-
3 files changed, 11 insertions(+), 2 deletions(-)
create mode 100644 docs/releases/0.31.7.md
diff --git a/docs/releases/0.31.7.md b/docs/releases/0.31.7.md
new file mode 100644
index 00000000..fd5b8551
--- /dev/null
+++ b/docs/releases/0.31.7.md
@@ -0,0 +1,9 @@
+# Thinker 0.31.7
+
+```bash
+docker pull tmsoftbrasil/thinker:0.31.7
+```
+
+## Mudanças
+
+- docs(tutoriais): stress do Thinker com o White Wizard (#1 só OpenAI; #2 todos os provedores, 12 harnesses e loop sem fim)
diff --git a/package-lock.json b/package-lock.json
index 910a54b0..3b1cb50f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14768,7 +14768,7 @@
}
},
"packages/thinker": {
- "version": "0.31.6"
+ "version": "0.31.7"
}
}
}
diff --git a/packages/thinker/package.json b/packages/thinker/package.json
index 46b1161c..54fafce7 100644
--- a/packages/thinker/package.json
+++ b/packages/thinker/package.json
@@ -1,6 +1,6 @@
{
"name": "thinker",
- "version": "0.31.6",
+ "version": "0.31.7",
"private": true,
"description": "Canonical release version for the Thinker Docker image. This package holds no code — the real backend and frontend live in ../backend and ../frontend. See README.md for details."
}