f3router docs

English · 简体中文

Catalog Sync — Deep Dive

Source: server/src/services/catalog-sync.ts, server/src/services/model-state.ts, server/src/db/migrations/

1. Overview

The catalog sync keeps the local model catalog in step with the published catalog at f3router.co. It runs twice daily (and on demand), fetches a signed catalog, verifies it against a pinned Ed25519 public key, and applies it transactionally to the local SQLite database.

Two Tiers

Tier Audience Refresh Cadence Auth
Live Premium license holders Every 2–3 days Bearer premium_license_key
Monthly Free installs ~30 days None (public)

Free installs still self-heal, just on a slower cadence. The bundled migrations remain the baseline — a fetched catalog is applied only when newer than MIN_CATALOG_VERSION (bumped on every model migration), so a stale monthly snapshot can never roll back models added by a newer app version.


2. Catalog Structure

interface Catalog {
  version: string;           // e.g. "2026.06.07"
  generatedAt: string;       // ISO timestamp
  tier: 'live' | 'monthly';
  models: CatalogModel[];    // chat models
  embeddings?: CatalogEmbedding[];       // optional, joined freshness feed
  transcriptionModels?: CatalogTranscriptionModel[]; // STT, own top-level key
  quirks: CatalogQuirk[];    // operational advisories
}

CatalogModel (chat)

Field Purpose
platform, modelId Primary key (with endpoint_scope for relays)
displayName UI name
intelligenceRank 1..1000 (1 = best), within provider
speedRank 1..11 (catalog hand-assigned, 1 = fastest)
sizeLabel Frontier | Large | Medium | Small (cross-provider tier)
limits {rpm, rpd, tpm, tpd} — per-model caps
monthlyTokenBudget String label (e.g. "1M", "2x") for headroom guardrail
contextWindow Max context (tokens)
enabled Catalog-level enable (force-disables if false)
supportsVision / supportsTools Capability flags
modality 'text' (default) → models table; 'image'/'audio' → media_models
mediaNote Short display for media models
requestStyle Adapter flavor (e.g. Cloudflare images: 'json' | 'multipart')

Generative Media & Transcription

  • Media models (modality: 'image' | 'audio') → media_models table, gated by MEDIA_PLATFORMS set.
  • Transcription models → media_models with modality = 'transcription', gated by TRANSCRIPTION_PLATFORMS.
  • Separate top-level keys (transcriptionModels, embeddings) so older binaries ignore unknown modalities instead of misrouting them as chat models.

3. Signature Verification

const PINNED_CATALOG_PUBKEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAq9yv4+3EeyMHKsfVYBhkcz1lYgIXSUeHNnN6tNgYX3k=
-----END PUBLIC KEY-----`;

// Fetch
const res = await fetch(`${catalogBaseUrl()}/v1/latest`, { headers: { Authorization: `Bearer ${key}` } });
const signature = res.headers.get('x-catalog-signature');
const bytes = Buffer.from(await res.arrayBuffer());
const verified = crypto.verify(null, bytes, catalogPublicKey(), Buffer.from(signature, 'base64'));
  • Pinned key: private half never left catalog host.
  • Self-hosted catalog override: CATALOG_BASE_URL, CATALOG_PUBKEY env vars.
  • Tamper-proof: anything unsigned or modified is discarded. Compromised CDN / MITM cannot inject models or quirks.

4. Application Rules (User Data Protection)

Applied inside one transaction (applyCatalog):

Rule Behavior
Metadata (name, ranks, limits, context, capabilities) Tracks catalog unless user has explicit local override
enabled: false Force-disables (model dead upstream)
enabled: true Never re-enables a model the user turned off
User-created rows (source = 'user') Never updated, never deleted, never adopted — on platform:model_id collision, user row wins, catalog entry skipped
Catalog models user deleted Stay deleted via tombstones (catalog_model_tombstones table)
Auto-retired (410/end-of-life) Disabled, not deleted — catalog listing them enabled lifts retirement (#634)
Models vanished from catalog Deleted (fallback_config first, FK order)

Provenance Column (source)

Replaces the old size_label NOT IN ('User','Custom') heuristic. Values:

  • 'catalog' — created by catalog sync
  • 'user' — declarative config, admin add, custom endpoint
  • 'declarative' — from declarative config file

5. Sync Flow

syncCatalog(force=false)
  ├─ GET /v1/latest?since=<appliedVersion> (with Bearer license if set)
  ├─ 304 → up_to_date
  ├─ Verify signature (Ed25519)
  ├─ Parse + structural validation (isCatalog)
  ├─ version < MIN_CATALOG_VERSION? → skipped_older (would roll back migrations)
  ├─ Same version+tier as applied? → up_to_date
  ├─ applyCatalog(db, catalog) → transactional upsert/insert/delete
  ├─ Persist settings: appliedVersion, appliedTier, appliedJSON (verified doc)
  └─ Log counts: updated, inserted, removed, skippedUnknownPlatform, quirks

Boot Re-apply (reapplyCachedCatalog)

Migrations run on every boot and re-assert the bundled baseline (INSERT OR IGNORE baseline models the catalog may have deleted). The boot-time network sync 304s on unchanged version and would NOT re-apply. Without this step, every restart drifts the DB back toward the baseline until the next catalog version bump.

reapplyCachedCatalog()
  ├─ Read SETTING_APPLIED_JSON (cached verified document)
  ├─ Validate + version ≥ MIN_CATALOG_VERSION
  ├─ applyCatalog(db, parsed) → synchronous, no network
  └─ Log: "re-applied cached live v2026.06.07 after boot"
  • Legacy upgrade: installs with applied-version but no cached doc → clear applied version → next poll fetches full catalog.

6. Model-Age Gate (30 Days)

Models older than 30 days from generatedAt are excluded from the live tier (free tier trailing snapshot). The catalog service enforces this; the client just receives what the tier provides.


7. License Status

interface LicenseStatus {
  valid: boolean;
  plan: 'annual' | 'lifetime' | null;
  status: string | null;
  expiresAt: string | null;
  checkedAtMs: number;
}
  • Cached in settings (premium_license_status), refreshed on every sync.
  • Entitlement enforced server-side at /v1/latest — local cache is informational UI state.
  • Offline / service down → keep cached status.

8. Quirks (Operational Advisories)

interface CatalogQuirk {
  slug: string;
  title: string;
  body: string;
  severity: 'blocker' | 'warning' | 'info';
  targets: { platform: string | null; modelGlob: string | null }[];
}
  • Pure content: replaced wholesale on every sync (DELETE + INSERT).
  • Dashboard surfaces blockers/warnings for affected models.

9. Settings Keys

Key Purpose
premium_license_key Bearer token for live tier
premium_license_status Cached LicenseStatus JSON
catalog_applied_version Last applied catalog version
catalog_applied_tier 'live' | 'monthly'
catalog_applied_json Verified catalog document (for boot re-apply)
catalog_last_sync_ms Timestamp of last successful sync
catalog_last_error Last sync error message

10. Scheduler Integration

startCatalogSync(scheduler: Scheduler)
  ├─ reapplyCachedCatalog()  // synchronous, no network
  ├─ scheduler.after(10s, run)  // boot delay
  └─ scheduler.every(12h, run)  // twice daily
  • run() = refreshLicenseStatus() + syncCatalog()
  • Kill switch: CATALOG_SYNC_DISABLED=1

11. Migration Seeding vs Hosted Catalog

Aspect Bundled Migrations Hosted Catalog Sync
Authority Baseline floor Live source of truth
Version MIN_CATALOG_VERSION (date) catalog.version (date)
New models Added via migration PR Appear in next catalog publish
Limit corrections Migration PR Learned from provider errors + catalog update
Retirements Migration (disabled + tombstone) Catalog enabled: false → force-disable
Relay models Not in catalog Custom endpoints only (user rows)
Rollback protection N/A version < MIN_CATALOG_VERSION → skipped

Custom Models / Relay Models

  • Never come from catalog. Created via:
    • POST /api/media/custom (generative media / STT)
    • POST /api/custom-endpoint (chat relays)
    • Declarative config file
  • Bound to api_keys row carrying their endpoint (key_id on models row).
  • endpoint_scope = 'custom:<base_url_hash>' distinguishes relays serving same model_id.
  • Catalog sync skips platform = 'custom' and unknown platforms (older binary).

12. Key Functions

Function Purpose
syncCatalog(force?) Fetch → verify → apply (or 304/skip)
applyCatalog(db, catalog) Transactional upsert/insert/delete + quirks
reapplyCachedCatalog() Boot-time re-apply from verified cache
refreshLicenseStatus() Validate license against catalog service
getSyncState() Dashboard status panel
startCatalogSync(scheduler) Register 12h interval + boot delay
isCatalog(value) Structural validation (fail loud on garbled body)
routableContextWindow(platform, modelId, cw) GitHub gpt-4.1 override (8000)

13. Tables Touched

Table Operation
models UPSERT chat models (catalog source)
media_models UPSERT image/audio/transcription
embedding_models UPSERT embeddings (full snapshot)
fallback_config Ensure every model has a row
catalog_model_tombstones Respect user deletions
quirks + quirk_targets Replace wholesale
settings Persist applied version/tier/json, license status, sync time

14. Environment Variables

Variable Default Purpose
CATALOG_BASE_URL https://api.f3tools.vercel.app Catalog service endpoint
CATALOG_PUBKEY Pinned Ed25519 key Override for self-hosted catalog
CATALOG_SYNC_DISABLED 0 Kill switch (1 = disabled)

15. Error Handling

Scenario Behavior
Network timeout (20s) Log warning, keep previous catalog
HTTP 304 up_to_date, update last_sync_ms
HTTP non-2xx Error, keep previous catalog
Missing signature Discard, error
Signature verification FAILED Discard, loud error — "catalog signature verification FAILED — discarding response"
Invalid JSON / shape Discard, error
Version older than baseline skipped_older, wait for newer catalog
DB unavailable during apply Transaction rolls back, error, keep previous state
License check unreachable Keep cached status, sync still runs (tier determined server-side)