Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions changelog.d/features/7818-custom-provider-tier.md
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -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 <ProviderTierField .../> 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.",
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 33 additions & 5 deletions src/app/(dashboard)/dashboard/TierCoverageWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderTier, TierBucket> = {
premium: "tier1",
cheap: "tier2",
free: "tier3",
};

export function classifyConnection(
providerId: string,
overrides: Record<string, ProviderTier>
): 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";
Expand All @@ -33,17 +53,25 @@ export function TierCoverageWidget() {
const [coverage, setCoverage] = useState<Coverage | null>(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<string, ProviderTier> = {};
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++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -900,6 +901,7 @@ export default function EditConnectionModal({
placeholder="my-app/1.0"
hint={t("customUserAgentHint")}
/>
<ProviderTierField provider={provider} />
{isM365TierCapable && (
<Select
label={t("m365TierLabel")}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"use client";

import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Select } from "@/shared/components";
import type { ProviderTier } from "@omniroute/open-sse/services/tierTypes";
import { fetchProviderTierOverride, saveProviderTierOverride } from "./providerTierField";

export interface ProviderTierFieldProps {
/** Provider connection id string — the same key `classifyTier()` matches on. */
provider?: string;
}

/**
* Generic per-connection tier-override selector (#7818).
*
* Unlike `m365Tier.ts`'s `M365_TIER_CAPABLE_PROVIDERS` allowlist (which gates a
* provider-specific surface), the tier override is a routing concern that
* applies uniformly to every connection, built-in or custom — no capability
* gate needed. Self-contained: fetches the current override on mount and
* persists a change immediately via the tier-config route, independent of the
* modal's own save flow (the override lives in the global `tier_config` table,
* not on the connection record).
*/
export default function ProviderTierField({ provider }: ProviderTierFieldProps) {
const t = useTranslations("providers");
const [tier, setTier] = useState<ProviderTier | "">("");
const [saving, setSaving] = useState(false);

useEffect(() => {
let cancelled = false;
if (!provider) return;
fetchProviderTierOverride(provider).then((value) => {
if (!cancelled) setTier(value);
});
return () => {
cancelled = true;
};
}, [provider]);

if (!provider) return null;

const handleChange = async (next: ProviderTier | "") => {
setTier(next);
setSaving(true);
try {
await saveProviderTierOverride(provider, next);
} finally {
setSaving(false);
}
};

return (
<Select
label={t("tierOverrideLabel")}
value={tier}
disabled={saving}
options={[
{ value: "", label: t("tierOverrideAuto") },
{ value: "free", label: t("tierOverrideFree") },
{ value: "cheap", label: t("tierOverrideCheap") },
{ value: "premium", label: t("tierOverridePremium") },
]}
onChange={(e) => handleChange(e.target.value as ProviderTier | "")}
hint={t("tierOverrideHelpText")}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Provider tier-override helpers (#7818).
*
* `classifyTier()` (`open-sse/services/tierResolver.ts`) already honors a
* DB-backed `providerOverrides` list keyed by an arbitrary provider-id string
* — it works identically for a built-in or a custom provider. These pure
* helpers + a thin fetch/save pair expose that mechanism through the new
* `/api/settings/tier-config` route so the Advanced Settings tier dropdown
* can be unit-tested without a DOM, mirroring `m365Tier.ts`'s shape in the
* same directory.
*/

import type { ProviderTier, TierConfig } from "@omniroute/open-sse/services/tierTypes";

const VALID_TIERS = new Set<ProviderTier>(["free", "cheap", "premium"]);

/** Normalize a stored override tier into the dropdown value ("" = unset/auto). */
export function normalizeTierValue(raw: unknown): ProviderTier | "" {
if (typeof raw === "string" && VALID_TIERS.has(raw as ProviderTier)) {
return raw as ProviderTier;
}
return "";
}

/** Fetch the current provider-tier override for `provider`, or "" when unset. */
export async function fetchProviderTierOverride(provider: string): Promise<ProviderTier | ""> {
const res = await fetch("/api/settings/tier-config");
if (!res.ok) return "";
const config = (await res.json()) as TierConfig;
const match = config.providerOverrides?.find(
(o) => o.provider.toLowerCase() === provider.toLowerCase()
);
return normalizeTierValue(match?.tier);
}

/** Set (or clear, when `tier === ""`) the tier override for `provider`. */
export async function saveProviderTierOverride(
provider: string,
tier: ProviderTier | ""
): Promise<void> {
const res = await fetch("/api/settings/tier-config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, tier: tier === "" ? null : tier }),
});
if (!res.ok) {
throw new Error(`Failed to save provider tier override (${res.status})`);
}
}
58 changes: 58 additions & 0 deletions src/app/api/settings/tier-config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { loadTierConfig, saveTierConfig } from "@/lib/db/tierConfig";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { setTierConfig } from "@omniroute/open-sse/services/tierResolver";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";

/**
* Settings route for a single provider's routing-tier override (#7818).
*
* `classifyTier()` (`open-sse/services/tierResolver.ts`) already honors
* `TierConfig.providerOverrides` — an array of `{ provider, tier }` keyed by the
* provider **id string** — but nothing exposed it through the API/UI for any
* provider, built-in or custom. This route is that missing surface: it reads
* and writes a single entry in that same array through the existing
* `tier_config` table (`loadTierConfig()`/`saveTierConfig()`), and busts the
* in-process routing cache via `setTierConfig()` so a change takes effect on
* the very next request without a restart.
*/

const tierOverridePutSchema = z.object({
provider: z.string().min(1),
tier: z.enum(["free", "cheap", "premium"]).nullable(),
});

export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
return NextResponse.json(loadTierConfig());
}

export async function PUT(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;

const rawBody = await request.json().catch(() => null);
const parsed = tierOverridePutSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json(buildErrorBody(400, "Invalid tier override payload"), {
status: 400,
});
}

const { provider, tier } = parsed.data;
const config = loadTierConfig();
const nextOverrides = config.providerOverrides.filter(
(o) => o.provider.toLowerCase() !== provider.toLowerCase()
);
if (tier !== null) {
nextOverrides.push({ provider, tier });
}
const nextConfig = { ...config, providerOverrides: nextOverrides };

saveTierConfig(nextConfig);
setTierConfig(nextConfig); // bust the in-process routing cache immediately

return NextResponse.json(nextConfig);
}
6 changes: 6 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4724,6 +4724,12 @@
"m365TierIndividualOption": "Individual (default)",
"m365TierEduOption": "Education",
"m365TierEnterpriseOption": "Enterprise / Work",
"tierOverrideLabel": "Tier override",
"tierOverrideAuto": "Auto (routing decides)",
"tierOverrideFree": "Free",
"tierOverrideCheap": "Cheap",
"tierOverridePremium": "Premium",
"tierOverrideHelpText": "Pin this provider to a routing tier instead of letting OmniRoute infer it from model pricing.",
"museSparkWebCookieHint": "Muse Spark Web Cookie Hint",
"museSparkWebCookiePlaceholder": "Muse Spark Web Cookie Placeholder",
"oauth": "Oauth",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -4722,6 +4722,12 @@
"m365TierIndividualOption": "Individual (padrão)",
"m365TierEduOption": "Educação",
"m365TierEnterpriseOption": "Empresarial / Trabalho",
"tierOverrideLabel": "Sobrescrever tier",
"tierOverrideAuto": "Automático (roteamento decide)",
"tierOverrideFree": "Gratuito",
"tierOverrideCheap": "Barato",
"tierOverridePremium": "Premium",
"tierOverrideHelpText": "Fixe este provedor em um tier de roteamento em vez de deixar o OmniRoute inferir a partir do preço do modelo.",
"museSparkWebCookieHint": "Cole o cookie abra_sess do meta.ai. Um cabeçalho de cookie completo também funciona.",
"museSparkWebCookiePlaceholder": "Cole o valor de abra_sess",
"oauth": "Oauth",
Expand Down
Loading
Loading