Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5f0c407
refactor(frontend): remove the SaaS layer from the dashboard
patrickbrandao Oct 10, 2026
d4abfb2
refactor(backend): remove billing, Stripe and plan limits
patrickbrandao Oct 10, 2026
b01370f
chore: merge feat/12-remove-saas-dashboard into feat/14 to stack on #51
patrickbrandao Oct 11, 2026
071ae49
refactor(frontend): remove phone-home and Autofix from the dashboard
patrickbrandao Oct 11, 2026
dd0ad62
fix(frontend): opt new harnesses out of legacy Autofix until #16
patrickbrandao Oct 11, 2026
64f31b4
refactor(backend): remove waitlist, CRM, Gemini Free, free models, Gi…
patrickbrandao Oct 11, 2026
4b6dc2f
chore: merge #13 branch to stack #16 on #13 and #15
patrickbrandao Oct 11, 2026
7dfb718
feat(backend)!: remove Autofix and the Phoenix healer from the backend
patrickbrandao Oct 11, 2026
a07cccf
feat(backend)!: remove phone-home from the backend and the CLI
patrickbrandao Oct 11, 2026
d168f73
fix(cli): default to a local gateway instead of Manifest Cloud
patrickbrandao Oct 11, 2026
7fcc7bf
feat(backend): single self-hosted mode, drop MANIFEST_MODE
patrickbrandao Oct 11, 2026
7b1f1b8
feat(backend): add THINKER_CATALOG_SYNC and gate the OpenCode Go doc …
patrickbrandao Oct 11, 2026
c83d102
fix(backend): pin custom-provider connections against DNS rebinding
patrickbrandao Oct 11, 2026
ce9fd33
Merge origin/main into feat/12-remove-saas-dashboard
patrickbrandao Oct 11, 2026
45a3af8
Merge the final #51 head into feat/14-remove-billing-backend
patrickbrandao Oct 11, 2026
061fbe1
Merge origin/main into feat/14-remove-billing-backend
patrickbrandao Oct 11, 2026
1213ffe
test(backend): route the billing drop e2e through the database guard
patrickbrandao Oct 11, 2026
56307a4
Merge the final #52 head into feat/15-remove-saas-backend
patrickbrandao Oct 11, 2026
a9a58eb
Merge origin/main into feat/15-remove-saas-backend
patrickbrandao Oct 11, 2026
498de23
test(backend): route the SaaS drop e2e through the database guard
patrickbrandao Oct 11, 2026
2538eb1
Merge the final #60 head into feat/16-remove-autofix-backend
patrickbrandao Oct 11, 2026
21ab015
test(backend): route the Autofix drop e2e through the database guard
patrickbrandao Oct 11, 2026
5eef655
Merge origin/main into feat/16-remove-autofix-backend
patrickbrandao Oct 11, 2026
631fda9
Merge feat/16-remove-autofix-backend head 5eef655 into feat/17-remove…
patrickbrandao Oct 11, 2026
d536bce
test(backend): route the install-metadata drop e2e through the databa…
patrickbrandao Oct 11, 2026
bfe9f1d
Merge origin/main into feat/17-remove-phonehome-backend
patrickbrandao Oct 11, 2026
907b752
Merge feat/17-remove-phonehome-backend head d536bce into feat/18-sing…
patrickbrandao Oct 11, 2026
1067728
test(backend): drop MANIFEST_MODE from local-runner specs and fix the…
patrickbrandao Oct 11, 2026
8aa729a
test(backend): escape host names fully in the egress spec patterns
patrickbrandao Oct 11, 2026
473e316
Merge the final #70 head into feat/18-single-selfhosted-mode
patrickbrandao Oct 11, 2026
409424b
Merge origin/main into feat/18-single-selfhosted-mode
patrickbrandao Oct 11, 2026
c149ed4
Merge feat/18-single-selfhosted-mode (409424b) into feat/39-catalog-sync
patrickbrandao Oct 11, 2026
d044aea
Merge origin/main into feat/39-catalog-sync
patrickbrandao Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,18 @@ BETTER_AUTH_SECRET=
# metadata endpoints stay blocked either way. Custom providers on your LAN
# (Ollama, LM Studio, vLLM) are always allowed and do not need this.
# THINKER_CIMD_ALLOW_PRIVATE=false


# ─── Public model catalogs (ADR 0003) ──────────────────────────────────────
#
# The only outbound calls Thinker makes on its own, besides the providers you
# configure:
# - openrouter.ai/api/v1/models and models.dev/api.json, at boot and once a
# day, for model prices, context windows and capabilities;
# - the OpenCode Go doc on raw.githubusercontent.com, only when an OpenCode
# Go provider is registered, for its model list and per-request cost.
# Set false to turn all three off (air-gapped installs). Thinker then uses only
# the bundled data, which covers few models: most requests are recorded with no
# known price and show as $0, OpenCode Go requests cost $0, and the model
# picker shows no prices for most models.
# THINKER_CATALOG_SYNC=true
16 changes: 14 additions & 2 deletions docker/DOCKER_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ refused, including when a hostname starts resolving to them after it was saved.
| `THINKER_CIMD_ALLOW_PRIVATE` | No | `false` | Set `true` to let MCP clients whose Client ID Metadata Document URL resolves to a private or loopback address sign in. Off by default to block SSRF from unauthenticated clients. |
| `MANIFEST_DISABLE_HSTS` | No | unset | Set `1` to silence the boot warning about serving over plain HTTP on a LAN |
| `THROTTLE_LIMIT` / `THROTTLE_TTL` | No | `100` / `60000` | Rate limit: requests per window, window in ms |
| `THINKER_CATALOG_SYNC` | No | `true` | Set `false` to stop fetching the public model catalogs (see [No telemetry](#no-telemetry)). Costs then come from the bundled data only, so most models show $0 |
| `DB_POOL_MAX` / `AUTH_DB_POOL_MAX` | No | `10` / `5` | PostgreSQL pool sizes (app pool, Better Auth pool) |

`NODE_ENV` and `SEED_DATA` are deliberately fixed by the compose file and are
Expand All @@ -429,8 +430,19 @@ Full env var reference:

The container makes no outbound call of its own: no usage telemetry, no update
check, no error reporting. It only reaches the inference providers you
configure and the public model catalogs (openrouter.ai, models.dev and, for
OpenCode Go, raw.githubusercontent.com).
configure and these public model catalogs:

| Destination | When |
| ---------------------------------------------- | ------------------------------------------------------------ |
| `openrouter.ai` (`/api/v1/models`) | at boot and once a day |
| `models.dev` (`/api.json`) | at boot and once a day |
| `raw.githubusercontent.com` (OpenCode Go docs) | only when an OpenCode Go provider is registered, cached 1 h |

Set `THINKER_CATALOG_SYNC=false` to turn all three off, for example on an
air-gapped host. Prices and capabilities then come only from the data bundled
in the image, which covers few models: requests to most models are recorded
without a known price and show as $0, OpenCode Go requests show $0, and the
pricing refresh endpoint reports `sync_enabled: false`.

## Links

Expand Down
5 changes: 5 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ services:
# Extra browser origins allowed to call the gateway (comma-separated).
# None by default: the dashboard is same-origin.
- THINKER_CORS_ORIGINS=${THINKER_CORS_ORIGINS:-}
# Public model/price catalogs (ADR 0003): openrouter.ai and models.dev at
# boot and daily, plus the OpenCode Go doc only when an OpenCode Go
# provider exists. Set false for air-gapped installs; costs then come
# from the bundled data only and most models show $0.
- THINKER_CATALOG_SYNC=${THINKER_CATALOG_SYNC:-true}
# Daily Harness usage rollup. Reads stay off until backfill parity passes.
- AGENT_USAGE_DAILY_WORKER=${AGENT_USAGE_DAILY_WORKER:-}
- AGENT_USAGE_DAILY_READS=${AGENT_USAGE_DAILY_READS:-}
Expand Down
18 changes: 17 additions & 1 deletion docs/dev/manifest-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,23 @@ Thinker makes no outbound call of its own (#17). Upstream Manifest had six, all
- **Hosted Wingman origin**: CORS no longer always allows `wingman.manifest.build`, and CSP `frame-src` is `'self'` in every mode (`WINGMAN_PORT` is gone). `WINGMAN_CORS_ORIGINS` became `THINKER_CORS_ORIGINS`, with the same meaning.
- **modelparams.dev**: `ProviderParamSpecService` serves only the snapshot of the bundled `modelparams` package, loaded synchronously at construction. There is no boot fetch, no hourly cron and no `MODELPARAMS_API_*`; the snapshot moves forward with dependency updates.

The only outbound destinations left are the providers the operator configures and the public catalogs of ADR 0003 (openrouter.ai, models.dev, and raw.githubusercontent.com for OpenCode Go). `test/no-phonehome-egress.e2e-spec.ts` fails if the backend, CLI or shared source names a removed destination again, or if a production-mode boot fetches anything outside that list.
The only outbound destinations left are the providers the operator configures and the public catalogs of [ADR 0003](../adr/0003-excecao-catalogos-publicos.md) (#39):

| Destination | Service | When |
|---|---|---|
| `openrouter.ai/api/v1/models` | `PricingSyncService` | boot (fire-and-forget) and daily at 03:00 |
| `models.dev/api.json` | `ModelsDevSyncService` | boot (fire-and-forget) and daily at 04:00, plus manual model refreshes |
| `raw.githubusercontent.com/.../go.mdx` | `OpencodeGoCatalogService` | only when an OpenCode Go provider is registered: the boot warmup checks `tenant_providers` first, and every other caller (that provider's discovery, its proxied requests and playground runs, its models in the picker) is already scoped to OpenCode Go; cached 1 h |

**`THINKER_CATALOG_SYNC=false`** (also `0`, `no`, `off`; read at call time by `isCatalogSyncEnabled()` in `common/utils/catalog-sync.ts`) turns all three off: no boot fetch, the crons and `POST /api/v1/routing/pricing/refresh` become no-ops, and the OpenCode Go catalog returns `[]` without fetching. `GET /api/v1/routing/pricing-health` and the refresh endpoint report `sync_enabled`, and the routing status (REST and the MCP `manifest_routing_status` tool) no longer blames the empty OpenRouter cache for an unroutable agent, since that cache is empty by design.

What the operator loses with the sync off, as ADR 0003 accepted:

- **Recorded cost.** `ModelPricingCacheService` is built from the OpenRouter and models.dev caches plus custom providers' own prices. With both caches empty, a request to a non-custom, non-local model is recorded with `cost_usd` NULL ("price unknown"), unless the provider reports its own cost in the response usage (OpenRouter-style `usage.cost`). The dashboard shows NULL as $0.00 and totals sum it as 0. Subscriptions stay $0 as before, local providers stay $0, and custom providers keep their configured prices.
- **OpenCode Go.** No docs catalog, so the per-request cost is $0, discovery lists only what OpenCode Go's own `/models` returns, and the API format comes from the model-family heuristic in `provider-client.ts`.
- **Model metadata.** Discovery falls back to `known-model-prices.ts` (three prefixes today) for prices and to `known-model-modalities.ts` for modalities. Most models show no price in the picker, capability flags from models.dev are missing, and providers whose model list comes from the catalogs when their own API cannot (for example a Gemini subscription, or a provider whose `/models` is unreachable) may list no models.

`test/no-phonehome-egress.e2e-spec.ts` fails if the backend, CLI or shared source names a removed destination again, or if a production-mode boot fetches anything outside that list. It boots the real server three times against the same database: with no OpenCode Go provider (openrouter.ai and models.dev are fetched, raw.githubusercontent.com is not), with an OpenCode Go provider and `THINKER_CATALOG_SYNC=false` (no remote fetch at all), and with that provider and the default (the OpenCode Go doc is warmed).

## Architecture Notes

Expand Down
3 changes: 3 additions & 0 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ BETTER_AUTH_URL=http://localhost:3001
# REQUEST_RECORDING_S3_SECRET_ACCESS_KEY=
# REQUEST_RECORDING_S3_FORCE_PATH_STYLE=false

# ── Public model catalogs (ADR 0003) ─────────────────
# THINKER_CATALOG_SYNC=true # Set false to stop the only outbound calls Thinker makes on its own: openrouter.ai and models.dev (boot + daily) and the OpenCode Go doc on raw.githubusercontent.com (only with an OpenCode Go provider). Costs then come from the bundled data only; most models show $0.

# ── Rate Limiting ────────────────────────────────────
# THROTTLE_TTL=60000 # Rate limit window in ms (default: 60000)
# THROTTLE_LIMIT=100 # Max requests per window (default: 100)
Expand Down
28 changes: 28 additions & 0 deletions packages/backend/src/common/utils/catalog-sync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { isCatalogSyncEnabled } from './catalog-sync';

describe('isCatalogSyncEnabled', () => {
it('is on by default (ADR 0003)', () => {
expect(isCatalogSyncEnabled({})).toBe(true);
});

it.each(['false', 'FALSE', ' false ', '0', 'no', 'off'])('is off for %p', (value) => {
expect(isCatalogSyncEnabled({ THINKER_CATALOG_SYNC: value })).toBe(false);
});

it.each(['true', '1', 'yes', 'on', ''])('stays on for %p', (value) => {
expect(isCatalogSyncEnabled({ THINKER_CATALOG_SYNC: value })).toBe(true);
});

it('reads process.env when no env is passed', () => {
const previous = process.env['THINKER_CATALOG_SYNC'];
try {
process.env['THINKER_CATALOG_SYNC'] = 'false';
expect(isCatalogSyncEnabled()).toBe(false);
delete process.env['THINKER_CATALOG_SYNC'];
expect(isCatalogSyncEnabled()).toBe(true);
} finally {
if (previous === undefined) delete process.env['THINKER_CATALOG_SYNC'];
else process.env['THINKER_CATALOG_SYNC'] = previous;
}
});
});
18 changes: 18 additions & 0 deletions packages/backend/src/common/utils/catalog-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* ADR 0003: the public model and price catalogs (openrouter.ai, models.dev and
* the OpenCode Go doc on raw.githubusercontent.com) are the only outbound
* destinations Thinker may reach on its own. `THINKER_CATALOG_SYNC=false`
* turns all three off; the bundled data is then the only source.
*
* Read at call time, not cached, so the value an operator sets is the value
* every boot hook, cron and refresh endpoint sees.
*/
export const CATALOG_SYNC_ENV = 'THINKER_CATALOG_SYNC';

const DISABLED_VALUES = new Set(['false', '0', 'no', 'off']);

export function isCatalogSyncEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env[CATALOG_SYNC_ENV];
if (raw === undefined) return true;
return !DISABLED_VALUES.has(raw.trim().toLowerCase());
}
23 changes: 23 additions & 0 deletions packages/backend/src/database/models-dev-sync.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,29 @@ describe('ModelsDevSyncService', () => {
fetchSpy.mockRestore();
});

describe('THINKER_CATALOG_SYNC=false', () => {
const previous = process.env['THINKER_CATALOG_SYNC'];
beforeEach(() => {
process.env['THINKER_CATALOG_SYNC'] = 'false';
});
afterEach(() => {
if (previous === undefined) delete process.env['THINKER_CATALOG_SYNC'];
else process.env['THINKER_CATALOG_SYNC'] = previous;
});

it('skips the boot fetch and resolves whenInitialized() at once', async () => {
service.onModuleInit();
await service.whenInitialized();
expect(fetchSpy).not.toHaveBeenCalled();
expect(service.getLastFetchedAt()).toBeNull();
});

it('makes the cron and the manual refresh a no-op', async () => {
await expect(service.refreshCache()).resolves.toBe(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
});

describe('refreshCache', () => {
it('should fetch, parse, and cache models from api.json', async () => {
fetchSpy.mockResolvedValue({
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/database/models-dev-sync.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CATALOG_SYNC_ENV, isCatalogSyncEnabled } from '../common/utils/catalog-sync';
import { normalizeProviderName, type ModelCapability, type ModelModality } from 'manifest-shared';
import { PROVIDER_BY_ID_OR_ALIAS } from '../common/constants/providers';
import {
Expand Down Expand Up @@ -283,6 +284,12 @@ export class ModelsDevSyncService implements OnModuleInit {
private initialLoad: Promise<void> | null = null;

onModuleInit(): void {
if (!isCatalogSyncEnabled()) {
this.logger.log(
`${CATALOG_SYNC_ENV}=false: models.dev sync disabled, using bundled data only`,
);
return;
}
// Fire-and-forget so a slow models.dev fetch can't delay app.listen() and
// trip Railway's healthcheck (see #1894). ModelPricingCacheService awaits
// whenInitialized() before its first reload so warmup still sees data.
Expand All @@ -300,6 +307,8 @@ export class ModelsDevSyncService implements OnModuleInit {

@Cron(CronExpression.EVERY_DAY_AT_4AM)
async refreshCache(): Promise<number> {
// Gates the boot fetch, the daily cron and the manual model refresh.
if (!isCatalogSyncEnabled()) return 0;
this.logger.log('Refreshing models.dev cache...');
const raw = await this.fetchModelsDevData();
if (!raw) return 0;
Expand Down
24 changes: 24 additions & 0 deletions packages/backend/src/database/pricing-sync.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@ describe('PricingSyncService', () => {
fetchSpy.mockRestore();
});

describe('THINKER_CATALOG_SYNC=false', () => {
const previous = process.env['THINKER_CATALOG_SYNC'];
beforeEach(() => {
process.env['THINKER_CATALOG_SYNC'] = 'false';
});
afterEach(() => {
if (previous === undefined) delete process.env['THINKER_CATALOG_SYNC'];
else process.env['THINKER_CATALOG_SYNC'] = previous;
});

it('skips the boot fetch and resolves whenInitialized() at once', async () => {
service.onModuleInit();
await service.whenInitialized();
expect(fetchSpy).not.toHaveBeenCalled();
expect(service.getAll().size).toBe(0);
expect(service.getLastFetchedAt()).toBeNull();
});

it('makes the cron and the refresh endpoint a no-op', async () => {
await expect(service.refreshCache()).resolves.toBe(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
});

describe('onModuleInit', () => {
it('kicks off refreshCache without blocking and populates the cache', async () => {
fetchSpy.mockResolvedValue({
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/database/pricing-sync.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CATALOG_SYNC_ENV, isCatalogSyncEnabled } from '../common/utils/catalog-sync';

interface OpenRouterModel {
id: string;
Expand Down Expand Up @@ -37,6 +38,12 @@ export class PricingSyncService implements OnModuleInit {
private initialLoad: Promise<void> | null = null;

onModuleInit(): void {
if (!isCatalogSyncEnabled()) {
this.logger.log(
`${CATALOG_SYNC_ENV}=false: OpenRouter pricing sync disabled, using bundled data only`,
);
return;
}
// Kick off the startup fetch WITHOUT blocking boot. A slow or unreachable
// OpenRouter previously stalled app.listen() — and with it Railway's
// healthcheck — for up to undici's default header timeout (see #1894).
Expand All @@ -57,6 +64,8 @@ export class PricingSyncService implements OnModuleInit {

@Cron(CronExpression.EVERY_DAY_AT_3AM)
async refreshCache(): Promise<number> {
// Gates the boot fetch, the daily cron and POST /routing/pricing/refresh.
if (!isCatalogSyncEnabled()) return 0;
this.logger.log('Refreshing OpenRouter pricing cache...');
const data = await this.fetchOpenRouterModels();
if (!data) return 0;
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/mcp/tools/routing.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { authOrigin } from '../../auth/auth.instance';
import { McpOperator, MCP_WRITE_SCOPE } from '../mcp-auth';
import { McpToolDeps } from '../tool-deps';
import { result } from '../tool-result';
import { isCatalogSyncEnabled } from '../../common/utils/catalog-sync';
import {
describeUnresolvedModel,
matchesModelName,
Expand Down Expand Up @@ -111,7 +112,9 @@ export function registerRoutingTools(
const agent = await deps.resolveAgent.resolve(operator.tenantId, agentName);
const providers = await deps.providers.getProviders(agent.tenant_id);
if (providers.length === 0) return { enabled: false, reason: 'no_provider' };
if (deps.pricingSync.getAll().size === 0)
// Empty by design with THINKER_CATALOG_SYNC=false, which does not
// stop routing.
if (isCatalogSyncEnabled() && deps.pricingSync.getAll().size === 0)
return { enabled: false, reason: 'pricing_cache_empty' };
const routable = await deps.tiers.hasRoutableTier(agent.id);
return { enabled: routable, reason: routable ? null : 'no_routable_models' };
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/mcp/tools/tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,19 @@ describe('MCP tools', () => {
(await call(registerAll(noPricing), 'manifest_routing_status', { agent: 'demo' })).data,
).toMatchObject({ reason: 'pricing_cache_empty' });

// With THINKER_CATALOG_SYNC=false the cache is empty by design and does
// not stop routing.
const previous = process.env['THINKER_CATALOG_SYNC'];
process.env['THINKER_CATALOG_SYNC'] = 'false';
try {
expect(
(await call(registerAll(noPricing), 'manifest_routing_status', { agent: 'demo' })).data,
).toMatchObject({ enabled: true, reason: null });
} finally {
if (previous === undefined) delete process.env['THINKER_CATALOG_SYNC'];
else process.env['THINKER_CATALOG_SYNC'] = previous;
}

const notRoutable = makeDeps();
(notRoutable.tiers.hasRoutableTier as jest.Mock).mockResolvedValue(false);
expect(
Expand Down
Loading
Loading