-- ========================================== -- Chat Switchboard — 003 Providers & Routing -- ========================================== -- Provider configs, model catalog, pricing, health, capability -- overrides, and routing policies. -- Consolidated v0.28.0: adds capability_match policy type. -- ========================================== -- ========================================= -- PROVIDER CONFIGS -- ========================================= CREATE TABLE IF NOT EXISTS provider_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), scope VARCHAR(10) NOT NULL CHECK (scope IN ('global', 'team', 'personal')), owner_id UUID, name VARCHAR(100) NOT NULL, provider VARCHAR(50) NOT NULL, endpoint TEXT NOT NULL, -- Encrypted API key (AES-256-GCM) api_key_enc BYTEA, key_nonce BYTEA, key_scope TEXT NOT NULL DEFAULT 'global', model_default VARCHAR(100), config JSONB DEFAULT '{}'::jsonb, headers JSONB DEFAULT '{}'::jsonb, settings JSONB DEFAULT '{}'::jsonb, is_active BOOLEAN DEFAULT true, is_private BOOLEAN DEFAULT false, proxy_mode TEXT NOT NULL DEFAULT 'system' CHECK (proxy_mode IN ('system', 'direct', 'custom')), proxy_url TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT chk_proxy_url CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL) ); CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope); CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id) WHERE owner_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active) WHERE is_active = true; DROP TRIGGER IF EXISTS provider_configs_updated_at ON provider_configs; CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK'; COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope'; COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)'; COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc'; COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK'; COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)'; COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)'; COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)'; COMMENT ON COLUMN provider_configs.proxy_mode IS 'system=env proxy, direct=no proxy, custom=explicit URL'; COMMENT ON COLUMN provider_configs.proxy_url IS 'Explicit proxy URL when proxy_mode=custom (http/https/socks5)'; -- ========================================= -- MODEL CATALOG -- ========================================= CREATE TABLE IF NOT EXISTS model_catalog ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, model_id TEXT NOT NULL, display_name TEXT, model_type VARCHAR(20) DEFAULT 'chat', capabilities JSONB NOT NULL DEFAULT '{}', pricing JSONB, visibility VARCHAR(10) DEFAULT 'disabled' CHECK (visibility IN ('enabled', 'disabled', 'team')), last_synced_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(provider_config_id, model_id) ); CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id); CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled'; CREATE INDEX IF NOT EXISTS idx_model_catalog_type ON model_catalog(model_type); DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog; CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN model_catalog.visibility IS 'disabled by default — admin must enable for global models'; COMMENT ON COLUMN model_catalog.model_type IS 'chat, embedding, image — from provider API sync'; COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}'; -- ========================================= -- MODEL PRICING -- ========================================= CREATE TABLE IF NOT EXISTS model_pricing ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, model_id TEXT NOT NULL, input_per_m NUMERIC(10,6), output_per_m NUMERIC(10,6), cache_create_per_m NUMERIC(10,6), cache_read_per_m NUMERIC(10,6), currency TEXT NOT NULL DEFAULT 'USD', source TEXT NOT NULL DEFAULT 'manual', updated_at TIMESTAMPTZ DEFAULT NOW(), updated_by UUID REFERENCES users(id), UNIQUE(provider_config_id, model_id) ); -- ========================================= -- PROVIDER HEALTH -- ========================================= CREATE TABLE IF NOT EXISTS provider_health ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, window_start TIMESTAMPTZ NOT NULL, request_count INT NOT NULL DEFAULT 0, error_count INT NOT NULL DEFAULT 0, timeout_count INT NOT NULL DEFAULT 0, total_latency_ms BIGINT NOT NULL DEFAULT 0, max_latency_ms INT NOT NULL DEFAULT 0, rate_limit_count INT NOT NULL DEFAULT 0, last_error TEXT, last_error_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (provider_config_id, window_start) ); CREATE INDEX IF NOT EXISTS idx_provider_health_window ON provider_health (provider_config_id, window_start DESC); -- ========================================= -- CAPABILITY OVERRIDES -- ========================================= CREATE TABLE IF NOT EXISTS capability_overrides ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE, model_id TEXT NOT NULL, field TEXT NOT NULL, value TEXT NOT NULL, set_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (provider_config_id, model_id, field) ); CREATE INDEX IF NOT EXISTS idx_capability_overrides_model ON capability_overrides (model_id); -- ========================================= -- ROUTING POLICIES -- ========================================= CREATE TABLE IF NOT EXISTS routing_policies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) NOT NULL, scope VARCHAR(10) NOT NULL DEFAULT 'global' CHECK (scope IN ('global', 'team')), team_id UUID REFERENCES teams(id) ON DELETE CASCADE, priority INTEGER NOT NULL DEFAULT 100, policy_type VARCHAR(30) NOT NULL CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias', 'capability_match')), config JSONB NOT NULL DEFAULT '{}'::jsonb, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_routing_policies_active ON routing_policies(is_active, priority) WHERE is_active = true; CREATE INDEX IF NOT EXISTS idx_routing_policies_team ON routing_policies(team_id) WHERE team_id IS NOT NULL; DROP TRIGGER IF EXISTS routing_policies_updated_at ON routing_policies; CREATE TRIGGER routing_policies_updated_at BEFORE UPDATE ON routing_policies FOR EACH ROW EXECUTE FUNCTION update_updated_at();