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." }