diff --git a/changelog.d/features/7818-custom-provider-tier.md b/changelog.d/features/7818-custom-provider-tier.md
new file mode 100644
index 0000000000..23d7d9a538
--- /dev/null
+++ b/changelog.d/features/7818-custom-provider-tier.md
@@ -0,0 +1 @@
+- feat(providers): let any provider connection — built-in or custom — be pinned to an explicit routing tier (free/cheap/premium) via a new `/api/settings/tier-config` route and an Advanced Settings tier selector; `TierCoverageWidget` now honors the override too (#7818)
diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json
index d011602064..d14fa8c549 100644
--- a/config/quality/file-size-baseline.json
+++ b/config/quality/file-size-baseline.json
@@ -1,4 +1,5 @@
{
+ "_rebaseline_2026_07_20_7818_provider_tier_field": "Issue #7818 (explicit tier override for any provider connection) own growth: EditConnectionModal.tsx 1285->1287 (+2 = import + a single render call, mirroring the m365Tier.ts precedent). All actual selector logic (fetch/save against the new /api/settings/tier-config route) lives in the new leaf src/app/(dashboard)/dashboard/providers/[id]/components/modals/ProviderTierField.tsx + providerTierField.ts (both well under cap). Covered by tests/unit/tier-config-provider-override-route.test.ts and tests/unit/tier-resolver-provider-override.test.ts.",
"_rebaseline_2026_07_19_7546_ghe_copilot_route": "PR #7546 (GHE Copilot OAuth provider) own growth: oauth/[provider]/[action]/route.ts 960->963 (gate units, +3 = ghe-copilot device-code wiring at the existing multi-provider device-code branch — reading + HTTPS-validating the gheUrl search param (isValidGheUrl guards at both raw entry points, security-review hardening, 963->970), adding ghe-copilot to the no-PKCE provider set, and building the provider config override / threading gheUrl through poll->postExchange extraData). Mirrors the existing kiro/amazon-q startUrl override pattern right above it in the same branch; cohesive with the existing device-code dispatch chokepoint, not separately extractable without splitting a single provider-switch mid-branch. Frozen so can only shrink; structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_6846_nvidia_concurrency_gate": "Issue #6846 Phase 1 (nvidia NIM local RPM budget + per-model lockout + per-connection concurrency cap) own growth: open-sse/executors/default.ts 877->890 (+13 = the irreducible call-site wiring at DefaultExecutor.execute(), the only place nvidia requests dispatch through — the existing session-pool body was extracted verbatim into a new private executeWithSessionPool() so the outer execute() can wrap it in the nvidia concurrency-gate acquire/finally-release). All actual gating logic (semaphore key + cap resolution) lives in the new leaf open-sse/executors/default/nvidiaConcurrencyGate.ts (not frozen, well under cap). Covered by tests/unit/nvidia-quota-phase1.test.ts.",
"_rebaseline_2026_07_18_v3849_provider_detail_wiring": "Merge campaign R2/R3 (2026-07-18): three authorized PRs each add irreducible call-site wiring to ProviderDetailPageClient.tsx — #7360 +5 (ProviderQuotaVisibilityToggle render, component extracted), #7419 +4 (NoAuthProviderControls wiring), #7062 +3 (Dahl provider hook) = 786->798. All three follow the extracted-component pattern (AgentrouterConsoleFields precedent); the frozen file only takes the wiring. Structural shrink tracked in #3501.",
@@ -220,7 +221,7 @@
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 798,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 942,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 967,
- "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1286,
+ "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1288,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
diff --git a/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx b/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx
index d356c6a578..ee665efdad 100644
--- a/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx
+++ b/src/app/(dashboard)/dashboard/TierCoverageWidget.tsx
@@ -4,14 +4,34 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { NOAUTH_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
+import type { ProviderTier } from "@omniroute/open-sse/services/tierTypes";
type TierCount = { configured: number; active: number };
type Coverage = { tier1: TierCount; tier2: TierCount; tier3: TierCount };
+type TierBucket = "tier1" | "tier2" | "tier3";
const NOAUTH_IDS = new Set(Object.keys(NOAUTH_PROVIDERS));
const OAUTH_IDS = new Set(Object.keys(OAUTH_PROVIDERS));
-function classifyConnection(providerId: string): "tier1" | "tier2" | "tier3" {
+/**
+ * Maps the routing-level `ProviderTier` (free/cheap/premium) onto this
+ * widget's own tier1/tier2/tier3 bucket vocabulary (#7818). The two do not
+ * line up 1:1 by name — premium (highest routing priority) is the widget's
+ * "Subscription" tier1 bucket, cheap is tier2, free is tier3 — so this stays
+ * a local mapping rather than renaming either enum.
+ */
+const OVERRIDE_TIER_TO_BUCKET: Record = {
+ premium: "tier1",
+ cheap: "tier2",
+ free: "tier3",
+};
+
+export function classifyConnection(
+ providerId: string,
+ overrides: Record
+): TierBucket {
+ const override = overrides[providerId.toLowerCase()];
+ if (override) return OVERRIDE_TIER_TO_BUCKET[override];
if (NOAUTH_IDS.has(providerId)) return "tier3";
if (OAUTH_IDS.has(providerId)) return "tier1";
return "tier2";
@@ -33,17 +53,25 @@ export function TierCoverageWidget() {
const [coverage, setCoverage] = useState(null);
useEffect(() => {
- fetch("/api/providers")
- .then((r) => r.json())
- .then((data) => {
+ Promise.all([
+ fetch("/api/providers").then((r) => r.json()),
+ fetch("/api/settings/tier-config")
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null),
+ ])
+ .then(([data, tierConfig]) => {
const connections: { provider: string; isActive: boolean }[] = data.connections ?? [];
+ const overrides: Record = {};
+ for (const o of tierConfig?.providerOverrides ?? []) {
+ overrides[String(o.provider).toLowerCase()] = o.tier;
+ }
const counts: Coverage = {
tier1: { configured: 0, active: 0 },
tier2: { configured: 0, active: 0 },
tier3: { configured: 0, active: 0 },
};
for (const conn of connections) {
- const tier = classifyConnection(conn.provider);
+ const tier = classifyConnection(conn.provider, overrides);
counts[tier].configured++;
if (conn.isActive) counts[tier].active++;
}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
index 30f97ab089..34d8ba6cb2 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
@@ -52,6 +52,7 @@ import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData";
import { isM365TierCapableProvider, normalizeM365TierValue, type M365TierValue } from "./m365Tier";
+import ProviderTierField from "./ProviderTierField";
import AgentrouterConsoleFields from "./AgentrouterConsoleFields";
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields";
@@ -900,6 +901,7 @@ export default function EditConnectionModal({
placeholder="my-app/1.0"
hint={t("customUserAgentHint")}
/>
+
{isM365TierCapable && (