Skip to content

feat(backend): THINKER_CATALOG_SYNC and OpenCode Go doc only with a provider (#39) - #72

Merged
patrickbrandao merged 33 commits into
mainfrom
feat/39-catalog-sync
Oct 11, 2026
Merged

patrickbrandao merged 33 commits into
mainfrom
feat/39-catalog-sync

Conversation

@patrickbrandao

@patrickbrandao patrickbrandao commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #39

Update (head d044aea). This PR is no longer stacked and targets main. #70 and #71 have both landed.

  • feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71 merge: I merged feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71's branch head 409424b into this branch (c149ed4). main (6887248) has the same tree, so it was then merged with -s ours (d044aea). The diff against main is only this PR's 22 files.
  • Conflicts: docker/.env.example keeps both new sections, THINKER_CIMD_ALLOW_PRIVATE and THINKER_CATALOG_SYNC.
  • Egress spec: the three-boot layout stays. One fix: every connection in it now goes through resolveE2eDatabaseUrl() instead of a localhost:5432 fallback. That covers the dropSchema reset, the provider insert and the spawned server's DATABASE_URL.
  • Re-run at c149ed4:
    • lint: 0 errors
    • typecheck: all 4 packages pass
    • unit tests: shared 525, cli 307, backend 9054, frontend 4178 (TZ=UTC)
    • test:e2e:docker: 70 suites / 592 tests pass, including the egress guard

Implements ADR 0003.

What changed

THINKER_CATALOG_SYNC. A new helper, isCatalogSyncEnabled() in packages/backend/src/common/utils/catalog-sync.ts, reads the variable at call time. It is on by default, and false, 0, no or off turn it off. When it is off, all three catalog sources stop:

  • OpenRouter (PricingSyncService) and models.dev (ModelsDevSyncService): no boot fetch, and refreshCache() returns 0 without fetching. That covers the daily crons, POST /api/v1/routing/pricing/refresh, and the models.dev refresh that manual model discovery runs. whenInitialized() resolves immediately, so the pricing cache warmup isn't held up.
  • OpenCode Go doc (OpencodeGoCatalogService): list() and refresh() return [] without fetching. The cost per request then becomes null, which is recorded as $0. The API format falls back to the model-family heuristic in provider-client.ts.

OpenCode Go only with a registered provider. The boot warmup (onModuleInit) now fetches only when a tenant_providers row exists whose provider is opencode-go or its alias opencodego, compared case-insensitively. Inactive rows count too. With no repository, or if the lookup fails, it skips the warmup rather than reach out. Every other caller is already scoped to an OpenCode Go provider:

  • ProviderModelFetcherService, during that provider's discovery
  • provider-client, when choosing the format for an opencode-go endpoint
  • the recorder and playground, for subscription cost on opencode-go
  • publishedOpencodeGoIds, gated on OpenCode Go models
  • the per-request cost in ModelController

Registering an OpenCode Go provider runs its discovery, which loads the doc lazily and caches it as before (1 h, 5 min backoff on errors).

Status hints. With the sync off, the OpenRouter cache is empty by design. As a result:

  • GET /routing/:agent/status no longer reports pricing_cache_empty.
  • The MCP manifest_routing_status tool no longer reports enabled: false, reason: 'pricing_cache_empty'. Before this change it did, even for agents that could route.
  • pricing-health and pricing/refresh now include sync_enabled. This is an additive field.

Egress guard (test/no-phonehome-egress.e2e-spec.ts). The test now boots the real production-mode server three times against the same disposable DB:

  1. No OpenCode Go provider. openrouter.ai and models.dev are fetched, so the default really is on. raw.githubusercontent.com is not fetched. Nothing is fetched outside ADR 0003, and no remote DNS lookups happen.
  2. OpenCode Go provider plus THINKER_CATALOG_SYNC=false: no remote fetch at all.
  3. OpenCode Go provider with the default setting: the doc on raw.githubusercontent.com is warmed. This is the positive control for the gate.

egress-recorder.cjs now writes a boot marker, so run 2 can prove the recorder was loaded even though it records no fetch. I also checked the test against the bug it guards: with the provider check and the OpenRouter gate removed, exactly the two new assertions fail.

Docs.

  • docker/.env.example and packages/backend/.env.example gained the variable.
  • docker/docker-compose.yml now passes THINKER_CATALOG_SYNC=${THINKER_CATALOG_SYNC:-true} to the container. The compose file lists its environment explicitly, so without this line the variable would never reach the container. This is a small touch in devops territory.
  • In docker/DOCKER_README.md, I added an env table row and rewrote the "No telemetry" section, which wrongly implied that raw.githubusercontent.com is always contacted. It now has a destination/trigger table and explains the opt-out.
  • In docs/dev/manifest-guide.md ("No phone-home"), I added the destination table, the opt-out semantics, the losses below, and the guard's three runs.

Pricing and cost display with the sync off (ADR 0003 accepted this)

I checked this in the code:

  • Recorded cost. ModelPricingCacheService is built only from the OpenRouter and models.dev caches plus custom providers' own prices. With both caches empty, a request to any other non-local model gets cost_usd = NULL ("price unknown"). The exception is a provider that reports usage.cost itself. The dashboard's formatCost shows NULL as $0.00, and totals add it as 0. Subscriptions, local providers and custom providers behave as before.
  • OpenCode Go. Per-request cost is $0. Discovery lists only what OpenCode Go's own /models returns, and the format comes from the family heuristic.
  • Model metadata. Discovery falls back to known-model-prices.ts, which today covers only 3 prefixes, and to known-model-modalities.ts. Most models show no price in the picker, and models.dev capability flags are missing. Some providers get their model list from the catalogs when their own API can't supply one, for example a Gemini subscription (CodeAssist) or a provider whose /models is unreachable. These may list no models.
  • A fuller snapshot generated at build time is out of scope (ADR 0003, Consequences). Note that known-model-prices.ts feeds discovery only, not computeTokenCost. If we want recorded costs with the sync off, that needs its own issue.

Gates (local; CI pending #38)

Gate Result
packages/shared build (tsc cjs + esm) pass
packages/shared unit tests 20 suites / 451 tests pass
packages/backend lint (eslint src/) 0 errors (323 pre-existing warnings)
packages/backend typecheck (tsc --noEmit) and nest build pass
packages/backend unit tests (jest) 466 suites / 8813 tests pass
packages/backend e2e, in band, TZ=UTC, disposable Postgres 16 (postgres:16-alpine on 127.0.0.1:55439, db prov39_test) 64 suites / 531 tests pass, including the tightened egress guard (21 tests)

Runtime behavior changed, so qa-docker should validate the image before this leaves draft. Suggested checks:

  • With the default setting and no OpenCode Go provider, there is no raw.githubusercontent.com traffic.
  • With THINKER_CATALOG_SYNC=false, the image makes no catalog traffic and the pricing refresh reports sync_enabled: false.

No live keys were used. The catalog fetches in tests are mocked or blocked by the egress recorder.

🤖 Generated with Claude Code

patrickbrandao and others added 12 commits October 10, 2026 15:21
Thinker is self-hosted only, so the inherited Manifest Cloud UI goes.
The frontend leaves first so no screen breaks when #14/#15 drop the
matching backend endpoints.

- Billing/plans: delete PlanPicker, UpgradeSuccessModal,
  UsageLimitBanner, the /upgrade page, the Register ?step=plan picker,
  the plan stores, the plan-based range locks on the analytics pages,
  the plan-limit upgrade CTAs in the request views, the Account billing
  and usage-email sections, and the @better-auth/stripe client plugin
  and dependency.
- API Bot: delete the banner, the sidebar card, the dithered ground,
  their stylesheet and the SVG asset.
- GitHub stars: drop the header star button and its /api/v1/github call.
- Free Models: delete the page, its route, its API client and the assets
  only it used.
- Managed Gemini Free: drop the provider entry, icon alias, background
  /ensure provisioning call and the Calendly key URL.
- Cloud branches: checkIsSelfHosted() is gone and every screen behaves
  as self-hosted (Local providers always listed, local logs copy, CLI
  login pinned to this host, discovery after signup and setup). Drop the
  Manifest Cloud origin mapping, the cloud email card, the
  VITE_MANIFEST_SELFHOSTED read and the Dockerfile ENV that set it.
- Help: drop the Manifest Calendly booking link.
- Remove the dead CSS for all of the above, update the tests and the
  inherited dev guide.

Closes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Billing, Stripe and the Free/Pro plans belong to the SaaS layer that
Thinker drops (docs/decisoes-iniciais.md section 3).

- Delete packages/backend/src/billing (PlanService, billing controller,
  billing emails, request quota window, subscription status) and drop
  BillingModule from the app, proxy and analytics modules.
- Proxy: remove the per-request plan gate (assertWithinRequestLimit) and
  the PLAN_LIMIT_REQUESTS / M204 handling in the controller and the
  exception filter. The proxy no longer answers 402 on its own; a 402
  follows the generic error path.
- Auth: remove the @better-auth/stripe plugin; drop the stripe and
  @better-auth/stripe dependencies from the backend.
- Recording retention: keep only the global path (365-day default or
  REQUEST_RECORDING_RETENTION_DAYS); per-plan retention is gone.
- Tenant entity: drop limit_overrides and billing_email_preferences;
  delete the TenantRequestUsage entity.
- Shared: delete plan-limits and its exports.
- Remove the STRIPE_* / PLAN_LIMIT_* / PLAN_REQUEST_QUOTA_RESET_AT
  variables from .env.example.
- New migration DropBillingSchema1803200000000 drops the quota trigger
  and function, requests.quota_counted, tenant_request_usage,
  billing_email_logs and the two tenant columns. The old
  AddTenantRequestUsage migration keeps only its self-hosted path
  (a no-op up) so it no longer imports deleted billing code.

/api/v1/billing/* no longer exists, and the STRIPE_* and PLAN_LIMIT_*
variables are ignored if set.

Closes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thinker makes no outbound calls of its own, and Autofix depended on
Manifest's hosted Phoenix service. The dashboard drops both before the
backend modules go in #16 and #17.

- Update check: VersionIndicator now only shows the build-time
  __MANIFEST_VERSION__ badge. It makes no request, shows no "new version"
  link to the manifest.build changelog and has no dismiss state.
  services/api/version.ts and its test are gone.
- Discovery: delete the post-signup questionnaire (page, service, styles,
  route, tests) and the pending-discovery redirects in AuthGuard and
  GuestGuard. First-admin setup now lands on / (Overview), signup on
  /welcome, and social signup also goes to /welcome. A leftover
  manifest_discovery_pending_* key from an older build no longer
  redirects anyone.
- Wingman: delete the dev drawer, wingman-dev-proxy.ts, the
  VITE_WINGMAN_* / __WINGMAN_URL__ / __DEV_MODE__ build constants, the
  App and Welcome mounts, the drawer CSS and the CONTRIBUTING section.
- Autofix: delete AutofixKpiCards, SettingsAutofixSection,
  NotificationBell (it only nagged about inactive Autofix), the Autofix
  switch and Manifest Terms/Privacy line on harness creation (modal and
  Welcome), the autofix badges, trigger filter, Phoenix panels, sibling
  links and status labels in the Requests views, the "Autofixed attempts"
  connection card, the Doctor tooltip variant, the healed chart series,
  the autofix fields in services/api/*, the two public PNGs, and the
  dead CSS.
- The generic reliability metrics that the backend still serves under
  the legacy /overview/autofix-* paths (success rate, fallback
  recoveries, request status over time, per harness/provider/model
  reliability) stay. They are now read through neutral clients
  (getReliabilityStats, getRequestStatusTimeseries, RELIABILITY_PATHS)
  and shown by ReliabilityKpiCards. "Self-healed" becomes "Recovered" and
  counts fallback recoveries only.
- Update the tests, the design-system registry and the inherited dev
  guide.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up for #57.

- A harness stored without autofix_enabled inherits the backend default.
  The backend turns Autofix ON when it detects no container (bare-metal
  npm start, dev runs), and the dashboard no longer has a switch to turn
  it off. Until #16 removes Autofix from the backend:
  - createAgent always sends autofix_enabled: false, which covers the
    Connect Harness modal, Welcome and ConnectProvider;
  - duplicateAgent follows the copy with PATCH
    /routing/:agent/autofix {enabled:false}, because the duplicate
    endpoint has no field for it.
  Both live in one commented block in services/api/agents.ts, with
  tests on the request payloads.
- The request-status timeseries client folds a legacy `healed` key into
  `success`. The backend's success rate already counts those requests
  as successes, so the bars still sum to the request total, Success +
  Recovered + Error matches the Success rate card, and Recovered stays
  fallback-only, like its KPI.
- The Requests page rewrites a stale ?trigger= value (for example
  ?trigger=autofix) on load to the filter it actually applies.
- Add AuthGuard tests for /cli/auth, with and without a session.
- Correct the dev guide: the self-hosted default is OFF only in a
  container or with MANIFEST_MODE=selfhosted. Document
  AUTOFIX_GLOBAL_ENABLED=false as the operator workaround for existing
  harnesses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tHub stars and error pages

Remove the Manifest Cloud and marketing surfaces from the backend:

- waitlist/ (pivot and Autofix claims) and its open-CORS pivot route
  (applyPivotClaimCors, PIVOT_CLAIM_PATH, PIVOT_CLAIM_CLOUD_ORIGIN).
- crm-metrics/ and CRM_METRICS_SECRET.
- The managed Gemini Free provider: routing/managed-free-provider/,
  common/constants/managed-free-providers.ts, the CREDITS_* variables, and
  its entries in provider-endpoints, provider-client, provider.service,
  provider-model-fetcher and routing.module. No other provider's endpoint,
  prefix handling, fallback order or tier resolution changes.
- free-models/ (sync from mnfst/awesome-free-llm-apis), github/ (stars from
  api.github.com) and error-pages/ (public and Peacock push routes, seeders,
  discovery), MANIFEST_PUBLIC_STATS and ERROR_PAGE_PUSH_SECRET.
- gemini-free and the managedFree flag from manifest-shared, and the
  regenerated CLI provider catalog.
- The plan_request_limit_exceeded error class left over from #14.

DropSaasFeatureSchema1803300000000 sets a transaction-local lock_timeout,
drops waitlist_claims, public_error_pages and the cloud-only
IDX_requests_autofix_healed index, and deletes every gemini-free connection
with its agent grants. Request history keeps its provider label, and routes
that still name gemini-free resolve as unavailable and fall through to their
fallbacks, as after a disconnect. down() restores the two tables as the
self-hosted path created them.

Closes #15

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Autofix sent failing requests to Manifest's hosted healer
(autofix.manifest.build) and pinged it at every production boot. Thinker
makes no such call (#16). The local self-heal of v0.6 will build on the
fallback chain on its own terms.

- Delete routing/autofix (AutofixService, healing clients, health probe,
  observation reporter, Phoenix types and vendored contract) and the
  contract:refresh script with its ajv/js-yaml devDependencies.
- Proxy: drop the heal-once branch from the primary and fallback hops and
  the wire-body retry hook from ForwardResult. A failed request now goes
  straight to shouldTriggerFallback, exactly as for an agent that never
  opted in. The primary is always attempt 1, the x-manifest-provider /
  x-manifest-auth-type replay rewrite is gone, and the recorder writes no
  repair columns or auto_fixed rows.
- Analytics: move the generic reliability metrics to
  /api/v1/overview/reliability/{stats,timeseries,per-agent,per-provider,
  per-model} without the Autofix fields; drop the healed disposition, the
  auto_fixed counter, the autofix trigger filter and the connection
  breakdown's Autofix attempts.
- Remove /autofix/status, /autofix/enable-all, the per-harness
  /routing/:agent/autofix endpoints, the autofix_enabled create field, the
  MCP Autofix tools and the CLI `routing autofix` command and `--autofix`.
- Schema: DropAutofixSchema1803400000000 drops the autofix columns, check
  constraint and indexes (lock_timeout, IF EXISTS, full revert).
  1802200000000 now runs its self-hosted no-op path everywhere.
- Shared: drop deriveAutofixStatus, AUTOFIX_STATUSES and auto_fixed.
- Delete the unused doctor-release email.
- Frontend: call the neutral reliability paths and drop the temporary
  autofix_enabled opt-out, the duplicate PATCH and the healed fold.
- Docs: move the PoC spec to docs/referencia/, remove AUTOFIX_* from the
  guide, .env.example and the Docker README.

BREAKING CHANGE: the Autofix API, analytics paths, MCP tools and CLI
command are removed; the reliability analytics moved to
/api/v1/overview/reliability/*.

Closes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thinker makes no outbound call of its own. Remove every upstream
phone-home from the backend and the CLI:

- usage telemetry (telemetry/, InstallIdService, PayloadBuilderService,
  the InstallMetadata entity) and the CLI usage spool
  (packages/cli/src/telemetry.ts);
- the daily update check (version/, GET /api/v1/version, the GitHub
  releases URL);
- the discovery forwarding to blue.manifest.build (discovery/,
  POST /api/v1/discovery/complete);
- Sentry (instrument.ts, sentry/, SentryModule/SentryGlobalFilter,
  the @sentry/nestjs dependency);
- the hosted Wingman origin in CORS and CSP frame-src; frame-src is
  'self' in every mode and WINGMAN_CORS_ORIGINS becomes
  THINKER_CORS_ORIGINS with the same meaning;
- the modelparams.dev fetch on boot and hourly cron; parameter specs
  come only from the bundled modelparams package snapshot.

DropInstallMetadata1803500000000 drops the install_metadata table
(lock_timeout, IF EXISTS, revert recreates it empty).

The removed variables leave docker-compose, the .env examples, the
Docker README, the deploy templates and the developer guide.

A new e2e guard fails if the backend, CLI or shared source names a
removed destination again, or if a production-mode boot of the real
server fetches or resolves anything outside the ADR 0003 catalogs.

BREAKING CHANGE: GET /api/v1/version and POST /api/v1/discovery/complete
are gone, the hosted Wingman origin is no longer allowed by CORS, and
WINGMAN_CORS_ORIGINS is renamed THINKER_CORS_ORIGINS.

Closes #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no --url, MANIFEST_URL or stored login, the CLI targeted
https://app.manifest.build, so a MANIFEST_API_KEY set without a host
went to Manifest's cloud. DEFAULT_URL is now http://localhost:2099, the
default Thinker port, and the invalid-URL hint and README follow.

The backend's 404 hint for /chat/completions told callers to point
their baseURL at https://app.manifest.build/v1; it now refers to this
gateway.

The egress guard now fails on app.manifest.build or
gateway.manifest.build anywhere in the CLI or shared source (tests
included), on a new backend production file naming them, on a CLI
default that is not local, and on a boot request to either host. The
three remaining backend files (Cloud trusted origins, the dashboard
link fallback and the OG-tag placeholder) are pinned for #18, #22 and
#25 and may only shrink.

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thinker has one deployment mode. The backend no longer decides between
"cloud" and "self-hosted" from MANIFEST_MODE or container markers
(/.dockerenv, /run/.containerenv, KUBERNETES_SERVICE_HOST); it always
keeps the former self-hosted behavior, on any runtime:

- built-in local providers (Ollama, LM Studio, llama.cpp) are always
  available: the cloud-only filters, the M303 rejection and its error
  code are gone;
- custom-provider URLs validate with allowPrivate: LAN, loopback and
  http:// destinations are a feature, http:// to a public host is still
  refused, and cloud metadata endpoints stay blocked (now also by name:
  metadata.google.internal, metadata.goog, metadata, instance-data);
- request recordings fall back to the filesystem without S3 credentials;
- the boot backfills always run on the app's DATABASE_URL connection;
- GET /api/v1/setup/status drops isSelfHosted and always probes Ollama.

The CIMD client_id fetch does NOT inherit the old self-hosted allowance:
that URL comes from an unauthenticated MCP client, so it is public-only
by default (DNS failure is an error), with THINKER_CIMD_ALLOW_PRIVATE=true
as the opt-in for LAN-hosted clients.

auth.instance.ts no longer trusts the Manifest Cloud hosts
(app/gateway.manifest.build) as origins or MCP resources; MCP is bound to
the canonical BETTER_AUTH_URL origin only, and the file leaves the
no-phonehome allow-list.

detect-self-hosted.ts becomes container-host.ts with only
getContainerHostAlias(). Unit tests no longer depend on /.dockerenv.

Closes #18
Closes #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on a provider

Implements ADR 0003 (#39):

- THINKER_CATALOG_SYNC=false (also 0/no/off) turns off all three public
  catalog fetches: no boot fetch, the daily crons and the pricing/model
  refresh paths become no-ops, and the OpenCode Go catalog returns []
  without fetching. OpenRouter and models.dev stay on by default.
- OpencodeGoCatalogService only warms the raw.githubusercontent.com doc at
  boot when a tenant_providers row for OpenCode Go (id or alias) exists.
  Every other caller is already scoped to an OpenCode Go provider, so a
  newly registered provider loads the doc lazily through its discovery.
- pricing-health and pricing/refresh report sync_enabled, and the routing
  status (REST and MCP) no longer blames the OpenRouter cache, which is
  empty by design with the sync off.
- The egress e2e boots the real server three times: no OpenCode Go
  provider (OpenRouter and models.dev fetched, no raw.githubusercontent.com),
  provider + THINKER_CATALOG_SYNC=false (no remote fetch at all), and
  provider + default (the doc is warmed).
- Documents the variable in both .env.example files, the compose file,
  DOCKER_README and the dev guide, including what pricing loses when the
  sync is off.

Closes #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao

Copy link
Copy Markdown
Owner Author

Revisão do PR #72 (#39, THINKER_CATALOG_SYNC e OpenCode Go só com provedor cadastrado)

Revisei o diff empilhado git diff origin/feat/17-remove-phonehome-backend...7b1f1b86708a6ade7b6ad8db4e62d63f78489626, com base em d168f73 (#70). A especificação é o ADR 0003.

Resumo: não há blocker, e nenhum critério de risco alto se aplica. O PR não mexe em auth, crypto, chaves, migrations, arquivos de governança, workflows nem release, e não cria destino de saída novo. A consulta global em tenant_providers devolve só uma contagem e não expõe dados de outra organização. Os achados abaixo são todos nits.

O que verifiquei

1. Semântica da chave. isCatalogSyncEnabled() é lida no momento da chamada e cobre todos os caminhos que encontrei por grep (openrouter.ai, models.dev, githubusercontent, refreshCache(, fetchOpenRouterModels, fetchModelsDevData, startFetch e os métodos do catálogo do OpenCode Go):

  • Boot. PricingSyncService.onModuleInit e ModelsDevSyncService.onModuleInit retornam antes do fetch. whenInitialized() resolve na hora (initialLoad fica null), então o warmup do ModelPricingCacheService não trava.
  • Crons e endpoint. Os crons das 03:00 e 04:00 e POST /routing/pricing/refresh passam por refreshCache(), que tem o gate no topo.
  • Descoberta. O refresh do models.dev disparado pela descoberta manual (model-discovery.service.ts:410) também passa por refreshCache().
  • OpenCode Go. list() e refresh() têm gate. Como resolveCostPerRequest e resolveFormat passam por list(), ficam cobertos também.
  • Fora do escopo dos catálogos. O https://openrouter.ai/api/v1/models em provider-model-fetcher.service.ts:1031 é a descoberta do provedor OpenRouter configurado pelo operador. O api.pioneer.ai em provider-model-fetcher.service.ts:65 só roda para um provedor Pioneer configurado. Os dois já existiam e não são catálogo do ADR 0003.

2. Gate do OpenCode Go.

  • Boot. O warmup só roda se existir uma linha em tenant_providers com LOWER(provider) IN ('opencode-go','opencodego'), de qualquer tenant e mesmo inativa. Sem repositório ou com erro na consulta, ele não busca e registra um warn. Esse fail-closed é seguro: o primeiro uso real ainda aguarda list() em resolveCostPerRequest e resolveFormat, então o bug antigo de custo $0 na primeira requisição não volta.

  • Injeção. TenantProvider está no forFeature de ModelDiscoveryModule, e o serviço é provido só ali. Não encontrei outra instância sem repositório.

  • Lazy. Todos os chamadores ficam restritos a OpenCode Go:

    • a descoberta do provedor (provider-model-fetcher.service.ts:1386-1438), que só cai no doc se o /models ao vivo vier vazio;
    • provider-client.ts:557, com resolved === 'opencode-go';
    • o recorder (proxy-message-recorder.ts:1247), quando o provedor canônico é opencode-go e o tipo é subscription;
    • o playground (playground.service.ts:362), que antes exige hasActiveProvider;
    • ModelController (m.provider === 'opencode-go') e publishedOpencodeGoIds (sai cedo sem modelo OpenCode Go).

    Não achei caminho que busque o doc sem provedor cadastrado.

3. MCP e sync_enabled. A correção em routing.tools.ts:117 está certa para o caso com sync desligado. sync_enabled é um campo novo e aditivo. O tipo PricingHealth do dashboard (frontend/src/services/api/routing.ts:424) ignora o campo, e nada no frontend lê pricing_cache_empty de um jeito que quebre.

4. Egress guard. Rodei a suíte localmente em um Postgres descartável (postgres:16-alpine em 127.0.0.1:55472):

  • Execução normal. Os 21 testes passaram, em 85,7 s para as três subidas do servidor.
  • Mutação 1. Removi o gate de provedor (if (!(await this.hasOpencodeGoProvider())) return;) e falhou exatamente 1 teste.
  • Mutação 2. Removi o gate em pricing-sync.service.ts e falhou exatamente with an OpenCode Go provider registered › and THINKER_CATALOG_SYNC=false › fetches no catalog at all.

O teste pega as duas regressões. A rodada 3 serve de controle positivo do gate e da janela de 3 s, então risco de instabilidade por timing é baixo: a consulta ao banco começa no onModuleInit, antes do listen. As linhas deixadas no banco (egress-tenant e o provedor) não contaminam outras suítes, porque test/helpers.ts:153 usa dropSchema: true por padrão.

5. Compose e conflito com #71. THINKER_CATALOG_SYNC=${THINKER_CATALOG_SYNC:-true} (docker/docker-compose.yml:157) está correto: vazio ou ausente vira true, e o valor do .env passa para o container. Simulei com git merge-tree --write-tree 7b1f1b8 7fcc7bf (head de #71):

  • docker-compose.yml, DOCKER_README.md, manifest-guide.md, packages/backend/.env.example e o teste de egresso mesclam sem conflito.
  • O único conflito é textual, em docker/.env.example: os dois PRs acrescentam uma seção no fim do arquivo (THINKER_CATALOG_SYNC aqui e THINKER_CIMD_ALLOW_PRIVATE em feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71). A resolução é manter os dois blocos.

Quem entrar por último precisa de rebase, e um rebase gera head novo e exige revisão nova.

6. Precisão dos docs sobre preço com sync desligado. Conferi no código:

  • ModelPricingCacheService.reload usa só o cache do OpenRouter, o do models.dev e os provedores custom.
  • known-model-prices.ts só é importado em model-discovery.service.ts.
  • O Copilot por subscription continua com preço por token a partir dos cached_models dele (proxy-message-recorder.ts:394-431), o que bate com "subscriptions behave as before".
  • OpenCode Go com custo null e isSubscription resulta em $0.

O texto do PR, do DOCKER_README.md e do manifest-guide.md está correto.

Testes e gates.

Achados

Blockers: nenhum.

Should-fix: nenhum.

Nits

  1. Valor não reconhecido mantém a busca ligada sem aviso. Em packages/backend/src/common/utils/catalog-sync.ts:17, um operador que escreve THINKER_CATALOG_SYNC=disabled ou erra a digitação (flase) continua falando com openrouter.ai e models.dev, sem nenhum sinal. Isso segue o ADR (o padrão é ligado), mas contraria a intenção de quem tentou desligar. Sugestão: no boot, registrar um warn quando o valor não for vazio nem estiver em true/1/yes/on/false/0/no/off.

  2. Mensagens de log fixas em =false. Em pricing-sync.service.ts:43, models-dev-sync.service.ts:289 e opencode-go-catalog.service.ts:156, a mensagem diz THINKER_CATALOG_SYNC=false mesmo quando o valor é off ou 0. Sugestão: usar "is off" ou interpolar o valor real.

  3. MCP ainda diverge do REST com sync ligado. Em packages/backend/src/mcp/tools/routing.tools.ts:117-119, o MCP checa o cache vazio antes de hasRoutableTier, e o REST (provider.controller.ts:92-103) só checa quando não há tier roteável. Com o sync ligado, nos segundos depois do boot ou quando o fetch falha (instalação isolada sem a chave), o MCP ainda responde enabled: false, reason: 'pricing_cache_empty' para agentes que roteiam. Isso já existia, mas a descrição do PR dá a entender que o problema foi resolvido. Sugestão: em follow-up, alinhar com o REST, checando hasRoutableTier primeiro.

  4. Rodadas 2 e 3 não checam DNS. Em packages/backend/test/no-phonehome-egress.e2e-spec.ts:355 e :371, essas rodadas olham só os registros fetch. Só a rodada 1 confere que não houve DNS remoto. Sugestão: repetir nas rodadas 2 e 3 a asserção de DNS sem host remoto, para pegar http/https/net fora do fetch com o sync desligado.

  5. Rodadas 2 e 3 dependem da rodada 1. Em no-phonehome-egress.e2e-spec.ts:323-336, elas dependem do schema migrado pela rodada 1. Rodar só o bloco aninhado com -t contra um banco limpo quebra o INSERT INTO tenant_providers. Sugestão: fazer o beforeAll do bloco garantir o schema por conta própria, ou documentar a ordem no comentário.

  6. Toast de preço com sync desligado. Em packages/frontend/src/pages/Routing.tsx:343, o dashboard mostra o aviso "Model pricing data is unavailable" a cada visita à página de roteamento. O texto é verdadeiro, mas agora sync_enabled permite uma mensagem específica ("catalog sync is off"). Sugestão: follow-up no frontend.

  7. Gatilhos manuais fora da tabela. Em docker/DOCKER_README.md:435-436, a coluna "When" diz só "at boot and once a day". Faltam os gatilhos manuais: POST /routing/pricing/refresh para openrouter.ai e o refresh manual de modelos para models.dev. O manifest-guide.md já cita o segundo.

Gates pendentes

Com aprovado, não há should-fix pendente. Os nits são opcionais e podem virar issues de follow-up (sugiro 1 e 3).

Head revisado: 7b1f1b8
Veredito: aprovado

@patrickbrandao
patrickbrandao marked this pull request as ready for review October 11, 2026 05:35
Review of #71 (S1): with allowPrivate now constant for custom providers,
a DNS failure passed validatePublicUrl, and fetch then resolved the name
again with no check. A hostile resolver could answer a public address
(or fail) during validation and 169.254.169.254 at connect time, so the
probe and the forward could reach cloud metadata.

- New common/utils/ssrf-safe-fetch.ts: undici fetch with a shared Agent
  whose connect.lookup resolves once, refuses the connection when any
  answer is a metadata address, and hands the socket only validated
  addresses. Metadata literals and names are refused before any socket.
  A DNS failure fails the request.
- The custom-provider probe and the provider-client forward for
  user-supplied endpoints (requiresSsrfRevalidation) use it.
- validatePublicUrl treats a DNS failure as an error even with
  allowPrivate (a .invalid host is refused).
- S2: block the AWS container credential endpoints 169.254.170.2 (ECS),
  169.254.170.23 and fd00:ec2::23 (EKS Pod Identity).
- N2: classify IMDS embedded in IPv6 (IPv4-compatible, NAT64
  64:ff9b::/96, 6to4, Teredo) as metadata; IPv6 metadata matches any
  textual form.
- undici 6.29.0 becomes a backend dependency (the root override already
  pins undici ^6); Node's built-in fetch cannot take a foreign Agent
  across Node versions, so undici's own fetch is used.

Tests: unit specs for the lookup and the classifier, and an e2e where a
hostname resolves to a public address at validation and to
169.254.169.254 at connect: the probe and the forward are refused and a
listener standing in for the metadata address receives 0 connections
(it receives them when pinning is removed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
patrickbrandao and others added 12 commits October 11, 2026 13:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main now holds the squash of #51, whose tree equals ce9fd33, which this
branch already merged, so the result keeps this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #67

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #67

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in 498de23 (main plus #60) so #16 can merge into main after #60.
Conflicts came from #57's interim dashboard handling of the legacy Autofix
backend overlapping with this branch's backend removal:

- frontend analytics client: RELIABILITY_PATHS points at the neutral
  /overview/reliability/* routes. foldLegacyRecoveredKey is dropped: the
  backend computes the request disposition from success/fallback only and
  DropAutofixSchema removes requests.autofix_status, so no row can come back
  as "healed", and the dashboard ships with its own backend.
- frontend agents client: the auto-merged autofix_enabled opt-out on create
  and the PATCH /routing/:agent/autofix after duplicate are dropped (the field
  and the endpoint no longer exist; create rejects the field).
- shared: keep the playground-headers exports, drop the autofix-status ones.
- tests: keep this branch's versions (neutral paths, no opt-out, no fold).
- e2e specs from main (local providers, reserved local runner aliases): stop
  sending autofix_enabled on agent create.
- manifest guide: keep the "Autofix removed" section and neutral-path wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The DropAutofixSchema e2e spec opens its own DataSource with dropSchema,
so it now takes its URL from resolveE2eDatabaseUrl() like the other
migration specs instead of falling back to a default localhost database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
origin/main (44f1689, the squash of #60) has the same tree as 498de23,
which this branch already merged, so this merge only records main as an
ancestor and changes no file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-phonehome-backend

Brings in main (44f1689) through #66's final head so #70 merges cleanly
after #66.

Conflict resolutions:
- packages/backend/package.json, package-lock.json: keep #70's removal of
  @sentry/nestjs and take better-auth 1.7.7 from #5. The lockfile was
  re-checked with npm install --package-lock-only and needed no change.
- test/cors.e2e-spec.ts: keep #70's shared buildApp/buildDevApp/buildProdApp
  without the hosted Wingman origin; buildApp already serves on loopback (#77).
- test/drop-autofix-schema-migration.e2e-spec.ts: keep both the
  DropInstallMetadata revert from #70 and resolveE2eDatabaseUrl from #66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se guard

The DropInstallMetadata migration spec and the no-phone-home egress spec
both open a dropSchema connection. Resolve their URL with
resolveE2eDatabaseUrl() from test/e2e-db-guard instead of falling back to
localhost:5432, so neither can wipe a database that is not an explicit,
disposable test database (#67, #69).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao
patrickbrandao changed the base branch from feat/17-remove-phonehome-backend to main October 11, 2026 18:07
patrickbrandao and others added 7 commits October 11, 2026 15:07
main's tree equals 5eef655, already merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le-selfhosted-mode

Brings in main (#5, #63, #69, #100, #103, #104, #106, #107 and the SaaS,
billing, Autofix and phone-home removals) through #66 and #70.

Resolutions:
- package-lock.json: keep undici 6.29.0 as a runtime dependency (no
  "dev": true), since ssrf-safe-fetch imports it; the version bump from
  #65 is kept.
- src/mcp/mcp-discovery.spec.ts: keep the single-mode test (canonical
  resource and issuer whatever the Host header) and serve it on loopback
  like the rest of the spec (#77).
- src/common/constants/local-servers.ts and its spec: import
  getContainerHostAlias from common/utils/container-host, since
  detect-self-hosted no longer exists.
- design-system/registry: regenerate (npm run design:registry). The
  Playground header classes from #106 were missing from it, which the
  pre-commit check refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… local log flake

- Remove the MANIFEST_MODE=selfhosted setup that #100, #103 and #104 added
  to provider-client.local-servers.spec.ts, local-providers.e2e-spec.ts,
  reserved-local-runner-aliases.e2e-spec.ts and
  playground-request-headers.e2e-spec.ts. There is one mode now, so the
  variable does nothing.
- local-providers.e2e-spec.ts: "logs local requests under the canonical
  provider at zero cost" read rows[0] of every llama.cpp row for the agent,
  with no ORDER BY, while earlier tests also log llama.cpp requests and a
  row starts as `pending` before its usage lands. Tag the request with its
  own traceparent and wait for that one settled row. Closes that part of
  #101.
- mcp-cimd-ssrf.e2e-spec.ts: take the database from resolveE2eDatabaseUrl()
  instead of falling back to localhost:5432 before dropping the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeQL flagged js/incomplete-sanitization: only dots were escaped before
building a RegExp from the host constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's tree equals 8aa729a (#70), already merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #72 up to date with main 90598c0 plus #71 (single self-hosted mode,
ssrf-safe-fetch, local servers, playground headers, the e2e database guard,
the removals, x-api-key and better-auth 1.7.7).

Conflicts:
- docker/.env.example: keep both new sections, #71's
  THINKER_CIMD_ALLOW_PRIVATE and #72's THINKER_CATALOG_SYNC.
- test/no-phonehome-egress.e2e-spec.ts: keep #72's three-boot layout
  (default, OpenCode Go provider + THINKER_CATALOG_SYNC=false, OpenCode Go
  provider + default) and route every connection, including the dropSchema
  reset and the spawned server's DATABASE_URL, through
  resolveE2eDatabaseUrl() instead of a localhost:5432 fallback. #71's removal
  of auth.instance.ts from the hosted-gateway leftovers is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's tree (6887248, the #71 squash) equals 409424b, which is already merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao

Copy link
Copy Markdown
Owner Author

Re-revisão do PR #72 (#39) no head d044aea, agora com base em main

Revisei git diff origin/main...d044aea393b6cae6677354ecfc303ca76fc5116a, com origin/main em 6887248, numa worktree destacada. A revisão anterior foi no head 7b1f1b8 (veredito aprovado).

Resumo: não há blocker nem should-fix, e nenhum critério de risco alto se aplica. A mudança é a mesma que já foi aprovada em 7b1f1b8. As únicas diferenças são as resoluções de conflito e a passagem do teste de egresso pelo guard de e2e (#67). A CI está verde neste head.

Integridade dos merges

  • git diff 409424b 6887248 está vazio, ou seja, o head final de feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71 e main têm a mesma árvore.
  • git diff c149ed4 d044aea está vazio. O merge -s ours com main não mudou nada.
  • git diff origin/main...d044aea e git diff d168f73 7b1f1b8 (a mudança original) tocam os mesmos 22 arquivos. Comparei as linhas +/- arquivo por arquivo. Só packages/backend/test/no-phonehome-egress.e2e-spec.ts difere.
  • git diff 7b1f1b8 d044aea desse arquivo equivale a git diff d168f73 6887248 (o que feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71 e E2E: impedir dropSchema fora de banco de teste explícito #67 mudaram nele) mais a correção do guard:
    • withDatabase() agora usa resolveE2eDatabaseUrl(). Antes usava a constante DATABASE_URL com fallback para localhost:5432/mydatabase.
    • O DATABASE_URL do servidor iniciado no teste também passa a vir de resolveE2eDatabaseUrl().
    • A constante com fallback foi removida.
    • O resto veio de main: escapeRegExp, e auth.instance.ts saiu de BACKEND_HOSTED_GATEWAY_LEFTOVERS.
  • docker/.env.example mantém as duas seções, THINKER_CIMD_ALLOW_PRIVATE (de feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71) e depois THINKER_CATALOG_SYNC. O hunk contra main é só o bloco novo.

Guard de dropSchema

Todo uso de dropSchema em packages/backend/test e src passa por resolveE2eDatabaseUrl() no momento do uso:

  • helpers.ts:152
  • mcp-cimd-ssrf.e2e-spec.ts:151
  • message-error-code-migrations.e2e-spec.ts:97
  • agent-duplication-migrations.e2e-spec.ts:30
  • drop-saas-feature-schema-migration.e2e-spec.ts:72
  • no-phonehome-egress.e2e-spec.ts:127, em withDatabase, que cobre o reset com dropSchema e o insert do provedor. O servidor iniciado no teste usa o mesmo guard em :167.

Não sobrou nenhum fallback para um banco padrão. O globalSetup (test/jest-e2e.json:8) também aplica o guard antes de qualquer suíte rodar.

ADR 0003

  • Warmup no boot. OpencodeGoCatalogService.onModuleInit chama warmIfProviderRegistered() (opencode-go-catalog.service.ts:103-107). Esse método:

    • retorna se isCatalogSyncEnabled() for falso;
    • retorna se não existir linha em tenant_providers com LOWER(provider) IN ('opencode-go', aliases);
    • só então chama list().

    Sem repositório, ou com erro na consulta, ele não busca nada (fail-closed). O serviço só é provido em ModelDiscoveryModule, que tem TenantProvider em forFeature (model-discovery.module.ts:14). A consulta devolve só uma contagem global e não expõe dados de outra organização.

  • Provedor cadastrado depois do boot. O doc é carregado sob demanda.

    • Na descoberta do provedor: fetchOpencodeGoModels → fetchOpencodeGoDocsCatalog → list()/refresh() (provider-model-fetcher.service.ts:1399-1450). Isso só acontece se o /models ao vivo vier vazio.
    • No primeiro uso real: resolveCostPerRequest e resolveFormat chamam list() quando o índice está frio. Isso cobre o recorder (proxy-message-recorder.ts:1255), o provider-client.ts:614, o playground (playground.service.ts:365) e o model.controller.ts:162.

    Todos esses caminhos só rodam para opencode-go. A busca é feita uma vez e fica em cache por 1 h, com backoff de 5 min em caso de erro.

  • THINKER_CATALOG_SYNC=false. As três fontes ficam desligadas e os dados embutidos passam a ser a única fonte.

    • PricingSyncService e ModelsDevSyncService: o gate está no onModuleInit e no topo de refreshCache(). Isso cobre os crons, POST /routing/pricing/refresh e a chamada em model-discovery.service.ts:402.
    • OpencodeGoCatalogService: list() e refresh() devolvem [] sem fazer fetch.
  • Destinos. Por grep em backend/src, cli/src e shared/src, os únicos destinos de catálogo são openrouter.ai/api/v1/models, models.dev/api.json e o go.mdx em raw.githubusercontent.com. Também aparecem openrouter.ai em provider-model-fetcher.service.ts:1032 e em provider-endpoints.ts:565. Esses dois são o provedor OpenRouter configurado pelo operador, não catálogo.

  • modelparams.dev. Não sobrou nada em código de runtime: zero ocorrências em packages/backend, cli, shared e docker. O nome só aparece em testes do frontend (URL de "abrir issue", sem fetch) e no CHANGELOG.md histórico.

  • Teste de egresso. As três subidas continuam:

    1. Padrão sem provedor: busca openrouter.ai e models.dev, não busca o doc do OpenCode Go e não faz DNS remoto.
    2. Provedor OpenCode Go com sync desligado: nenhum fetch remoto.
    3. Provedor OpenCode Go com o padrão: aquece o doc.

    O marcador boot prova que o recorder carregou mesmo na rodada 2. O teste passou na CI deste head.

Testes e gates

  • CI em d044aea: todos os check runs (--paginate) terminaram com success:

    • Lint;
    • Unit (backend, cli, frontend, shared);
    • Backend e2e (PostgreSQL 16): 70 suítes e 592 testes, incluindo PASS test/no-phonehome-egress.e2e-spec.ts;
    • CodeQL (os dois check runs);
    • dependency-review;
    • install.sh end-to-end;
    • Compose smoke test.

    O code scanning não tem alerta aberto para refs/pull/72/head.

  • Report local do autor: está no corpo do PR, rodado em c149ed4 (que tem a mesma árvore deste head).

  • Changeset: não se aplica, pelo mesmo precedente da revisão anterior (os pacotes estão em ignore).

  • Título: segue Conventional Commits.

  • Identificadores legados: nenhum manifest/mnfst/MANIFEST_* novo.

  • qa-docker: não encontrei report no PR. O comportamento de runtime mudou, então esse gate continua pendente, com os dois checks sugeridos no corpo do PR.

Achados

Blockers: nenhum.

Should-fix: nenhum.

Nits (os 1 a 7 da revisão anterior continuam válidos e opcionais, e sigo sugerindo issues de follow-up para o 1 e o 3)

  1. A seção "No telemetry" ignora o fetch de CIMD que veio com feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71. Em docker/DOCKER_README.md:431-433, o texto diz que o container "only reaches the inference providers you configure and these public model catalogs". O .env.example (docker/.env.example:202) tem uma frase parecida. Desde feat(backend)!: single self-hosted mode, drop MANIFEST_MODE #71, o OAuth do MCP também busca o Client ID Metadata Document na URL que o cliente MCP informa (auth/cimd-client-metadata-fetch.ts).

    • O fetch é disparado pelo cliente, não pelo Thinker por conta própria, então não é phone-home e não viola o ADR 0003.
    • Mesmo assim, um operador de instalação isolada que lê essa tabela pode se surpreender.

    Sugestão: acrescentar uma linha ou nota ("MCP client metadata URL, only during an MCP client's OAuth sign-in, public addresses only unless THINKER_CIMD_ALLOW_PRIVATE=true"), aqui ou num follow-up de docs.

Classificação de risco

Nenhum critério de risco alto se aplica:

  • não mexe em auth, crypto, chaves ou tokens;
  • não tem migration;
  • a consulta nova é uma contagem global, sem leitura cruzada entre organizações;
  • não registra decisão de produto nova (implementa o ADR 0003 já aceito);
  • não toca arquivos de governança (docs/dev/manifest-guide.md não é um deles);
  • não mexe em .github/** e não é release.

Com aprovado, não há should-fix pendente. Os nits são opcionais. O merge ainda depende do qa-docker, porque o comportamento de runtime mudou. Este veredito vale só para o head abaixo, e qualquer commit novo exige nova revisão.

Head revisado: d044aea
Veredito: aprovado

@patrickbrandao

Copy link
Copy Markdown
Owner Author

qa-docker: passed (with 2 notes)

Tested SHA: d044aea393b6cae6677354ecfc303ca76fc5116a (includes main 6887248). Image built from docker/Dockerfile as thinker:qa-72d-pr (arm64, 429 MB, 27.6 s with a warm layer cache). Base images: node:24-alpine, node:24-slim, gcr.io/distroless/nodejs24-debian13:nonroot (all digest-pinned in the Dockerfile). Postgres: postgres:16-alpine (16.15, digest 721873c3...). Every run used its own Compose project, a volume override with name: !reset null (resolved names checked to carry the run id), label checks before down -v, and fresh volumes. No volumes or containers left behind.

Egress (tcpdump in the app network namespace, decoded with tshark: DNS names, TLS SNI, destination IPs)

Capture started before the app's first log line in every run. Each scenario covers boot, first-admin setup, a signed-in headless Chromium dashboard session and idle time.

# Scenario Remote destinations seen Result
1 PR, default env, no OpenCode Go provider (boot + setup + session + 120 s idle) openrouter.ai, models.dev (one fetch each, at boot). No raw.githubusercontent.com. pass
2 PR, default env, then OpenCode Go provider with a fake key via the API raw.githubusercontent.com first appears about 1 s after POST /api/v1/routing/qa-agent/providers (eager, triggered by the provider's discovery, not lazily on a later request). After an app restart with the provider present, all three are fetched at boot (the boot warmup). pass
3 PR, THINKER_CATALOG_SYNC=false, OpenCode Go provider present (before and after a restart, about 220 s total) None. Zero IP packets to non-private addresses, zero DNS names, zero TLS handshakes. App logs THINKER_CATALOG_SYNC=false: ... sync disabled, using bundled data only for both syncs. pricing-health returns {"model_count":0,"sync_enabled":false}. pass
4 origin/main (6887248), default env openrouter.ai, models.dev, raw.githubusercontent.com, all at boot as expected

Notes on the capture:

  • To keep the OpenCode Go case safe, opencode.ai was mapped to 127.0.0.1 in the app container (extra_hosts), so the provider's own discovery call (a one-off connection attempt, not a catalog fetch) never left the host. Provider-endpoint traffic is out of scope for ADR 0003 and is not counted above. Nothing else was pointed at an external host; the mock upstream ran on the host.
  • Known and separate: GET /api/v1/setup/status probes the host Ollama at host.docker.internal:11434 (about 15 connections per run). Same on main, tracked in Setup: não sondar o Ollama local sem provedor configurado #109, not a failure.

Functional

Check Result
Fresh-volume smoke: dashboard HTML, first-admin setup, sign-in, agent + key, mock provider via a host mock pass (7/7 and 5/5 steps)
Gateway: /v1/models, chat completions (stream and non-stream), messages with Bearer and with x-api-key (stream and non-stream), responses (stream and non-stream), no key gives 401 pass (9/9, in each of the three PR/main stacks)
Dashboard in headless Chromium, UI sign-in and 12 pages (overview, harnesses, routing, providers, messages, model prices, playground, account) pass: 108 to 123 API calls per run, all 200, no console errors
Contract tests contracts/gateway/ against the running container pass (6/6)
Backend e2e TZ=UTC npm run test:e2e:docker --workspace=packages/backend (shared built first) pass: 70 suites, 592 tests, 142 s

Notes (not blocking)

  1. With THINKER_CATALOG_SYNC=false the price catalog is empty, not just thin: GET /api/v1/model-prices returns {"models":[],"lastSyncedAt":null}, the Model Prices page shows "0 models", and the routing page shows the toast "Model pricing data is unavailable". The .env.example/DOCKER_README text says the bundled data "covers few models" and the log says "using bundled data only"; in practice nothing is listed. Custom-provider prices and /v1/models still work. Consider wording the docs and log line to match ("no prices", not "few").
  2. Boot logs ERROR [Better Auth]: Database schema mismatch ... Missing tables once on a fresh volume before migrations run. Identical on main, so not from this PR.

@patrickbrandao
patrickbrandao merged commit 99ffda4 into main Oct 11, 2026
11 checks passed
@patrickbrandao
patrickbrandao deleted the feat/39-catalog-sync branch October 11, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Catálogos públicos: chave THINKER_CATALOG_SYNC e OpenCode Go só com provedor cadastrado

1 participant