mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
classifyTier() already honored a DB-backed providerOverrides list keyed by an arbitrary provider-id string (built-in or custom), but nothing exposed it through the UI or API. Adds GET/PUT /api/settings/tier-config, a generic Advanced Settings tier selector wired into EditConnectionModal, and makes TierCoverageWidget consult the same override before falling back to registry-membership classification. Owner decision: scope is the 3 real ProviderTier machine values (free/cheap/premium) — the enum is not extended to 4.
This commit is contained in:
committed by
GitHub
parent
a7a6b5d016
commit
6770a57131
1
changelog.d/features/7818-custom-provider-tier.md
Normal file
1
changelog.d/features/7818-custom-provider-tier.md
Normal file
@@ -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)
|
||||
@@ -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.",
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
<ProviderTierField provider={provider} />
|
||||
{isM365TierCapable && (
|
||||
<Select
|
||||
label={t("m365TierLabel")}
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
src/app/api/settings/tier-config/route.ts
Normal file
58
src/app/api/settings/tier-config/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
101
tests/unit/provider-tier-field-helpers.test.ts
Normal file
101
tests/unit/provider-tier-field-helpers.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Client-side fetch helpers behind the Advanced Settings tier-override
|
||||
* selector (#7818). Pure integration logic — no DOM — unit-testable by
|
||||
* stubbing global.fetch, mirroring `tests/unit/agent-bridge-maintenance-api.test.ts`.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { normalizeTierValue, fetchProviderTierOverride, saveProviderTierOverride } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/providerTierField.ts"
|
||||
);
|
||||
|
||||
type FetchCall = { url: string; init?: RequestInit };
|
||||
|
||||
function stubFetch(handler: (call: FetchCall) => { ok: boolean; status?: number; body?: unknown }) {
|
||||
const calls: FetchCall[] = [];
|
||||
const original = global.fetch;
|
||||
global.fetch = (async (url: string, init?: RequestInit) => {
|
||||
const call = { url: String(url), init };
|
||||
calls.push(call);
|
||||
const { ok, status = ok ? 200 : 500, body } = handler(call);
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
global.fetch = original;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("normalizeTierValue maps valid tiers through and everything else to unset", () => {
|
||||
assert.equal(normalizeTierValue("free"), "free");
|
||||
assert.equal(normalizeTierValue("cheap"), "cheap");
|
||||
assert.equal(normalizeTierValue("premium"), "premium");
|
||||
assert.equal(normalizeTierValue(undefined), "");
|
||||
assert.equal(normalizeTierValue(null), "");
|
||||
assert.equal(normalizeTierValue("gold"), "");
|
||||
assert.equal(normalizeTierValue(""), "");
|
||||
});
|
||||
|
||||
test("fetchProviderTierOverride returns the matching override (case-insensitive)", async () => {
|
||||
const stub = stubFetch(() => ({
|
||||
ok: true,
|
||||
body: {
|
||||
providerOverrides: [{ provider: "My-Custom-Endpoint", tier: "premium" }],
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const result = await fetchProviderTierOverride("my-custom-endpoint");
|
||||
assert.equal(result, "premium");
|
||||
assert.equal(stub.calls[0].url, "/api/settings/tier-config");
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("fetchProviderTierOverride returns '' when no override exists or the request fails", async () => {
|
||||
const okStub = stubFetch(() => ({ ok: true, body: { providerOverrides: [] } }));
|
||||
try {
|
||||
assert.equal(await fetchProviderTierOverride("unset-provider"), "");
|
||||
} finally {
|
||||
okStub.restore();
|
||||
}
|
||||
|
||||
const failStub = stubFetch(() => ({ ok: false, status: 500 }));
|
||||
try {
|
||||
assert.equal(await fetchProviderTierOverride("any-provider"), "");
|
||||
} finally {
|
||||
failStub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("saveProviderTierOverride PUTs the provider + tier, and null when clearing", async () => {
|
||||
const stub = stubFetch(() => ({ ok: true, body: {} }));
|
||||
try {
|
||||
await saveProviderTierOverride("my-custom-endpoint", "cheap");
|
||||
const body = JSON.parse(String(stub.calls[0].init?.body));
|
||||
assert.equal(stub.calls[0].init?.method, "PUT");
|
||||
assert.deepEqual(body, { provider: "my-custom-endpoint", tier: "cheap" });
|
||||
|
||||
await saveProviderTierOverride("my-custom-endpoint", "");
|
||||
const clearBody = JSON.parse(String(stub.calls[1].init?.body));
|
||||
assert.deepEqual(clearBody, { provider: "my-custom-endpoint", tier: null });
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("saveProviderTierOverride throws on a non-OK response", async () => {
|
||||
const stub = stubFetch(() => ({ ok: false, status: 400 }));
|
||||
try {
|
||||
await assert.rejects(() => saveProviderTierOverride("my-custom-endpoint", "free"));
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
131
tests/unit/tier-config-provider-override-route.test.ts
Normal file
131
tests/unit/tier-config-provider-override-route.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Regression test for #7818 — a custom provider (or any provider) can be
|
||||
* pinned to a routing tier through the new /api/settings/tier-config route.
|
||||
*
|
||||
* Before this route existed, the DB-backed `providerOverrides` mechanism in
|
||||
* `classifyTier()` was reachable in code but had no HTTP surface — this test
|
||||
* exercises the route module directly (GET/PUT) against a real, isolated
|
||||
* SQLite test DB, per the `tests/unit/model-aliases-settings-route-selfheal.test.ts`
|
||||
* convention (isolated DATA_DIR + resetDbInstance in beforeEach/after).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-tier-config-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/settings/tier-config/route.ts");
|
||||
|
||||
function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function putRequest(body: unknown) {
|
||||
return new Request("http://localhost/api/settings/tier-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function getRequest() {
|
||||
return new Request("http://localhost/api/settings/tier-config");
|
||||
}
|
||||
|
||||
test("PUT sets a tier override for a custom (non-registry) provider id, and GET returns it", async () => {
|
||||
const customProviderId = "my-custom-endpoint-123";
|
||||
|
||||
const putRes = (await route.PUT(
|
||||
putRequest({ provider: customProviderId, tier: "premium" })
|
||||
)) as Response;
|
||||
assert.equal(putRes.status, 200, "PUT should succeed");
|
||||
const putBody = await putRes.json();
|
||||
assert.deepEqual(
|
||||
putBody.providerOverrides,
|
||||
[{ provider: customProviderId, tier: "premium" }],
|
||||
"PUT response should reflect the new override"
|
||||
);
|
||||
|
||||
const getRes = (await route.GET(getRequest())) as Response;
|
||||
assert.equal(getRes.status, 200);
|
||||
const getBody = await getRes.json();
|
||||
assert.deepEqual(
|
||||
getBody.providerOverrides,
|
||||
[{ provider: customProviderId, tier: "premium" }],
|
||||
"GET should return the persisted override — proves the custom provider gap in #7818 is closed"
|
||||
);
|
||||
});
|
||||
|
||||
test("PUT with tier: null clears an existing override without touching others", async () => {
|
||||
await route.PUT(putRequest({ provider: "custom-a", tier: "cheap" }));
|
||||
await route.PUT(putRequest({ provider: "custom-b", tier: "free" }));
|
||||
|
||||
const clearRes = (await route.PUT(putRequest({ provider: "custom-a", tier: null }))) as Response;
|
||||
assert.equal(clearRes.status, 200);
|
||||
const body = await clearRes.json();
|
||||
assert.deepEqual(
|
||||
body.providerOverrides,
|
||||
[{ provider: "custom-b", tier: "free" }],
|
||||
"clearing custom-a should leave custom-b's override untouched"
|
||||
);
|
||||
});
|
||||
|
||||
test("PUT with an invalid tier value is rejected with 400", async () => {
|
||||
const res = (await route.PUT(putRequest({ provider: "custom-a", tier: "gold" }))) as Response;
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error, "should return a structured error body");
|
||||
assert.ok(
|
||||
!JSON.stringify(body).includes("at /"),
|
||||
"error body must not leak a stack trace (ERROR_SANITIZATION.md)"
|
||||
);
|
||||
});
|
||||
|
||||
test("PUT with an empty provider string is rejected with 400", async () => {
|
||||
const res = (await route.PUT(putRequest({ provider: "", tier: "free" }))) as Response;
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("route round-trips cleanly against an already-populated tier_config table (no migration involved, #7818)", async () => {
|
||||
// Simulate an existing installation whose tier_config row was already
|
||||
// written by migration 059 (or a prior save) before this PR — this PR adds
|
||||
// no schema change, so GET/PUT must work unmodified against that row.
|
||||
const { saveTierConfig } = await import("../../src/lib/db/tierConfig.ts");
|
||||
const { DEFAULT_TIER_CONFIG } = await import("../../open-sse/services/tierConfig.ts");
|
||||
saveTierConfig({
|
||||
...DEFAULT_TIER_CONFIG,
|
||||
providerOverrides: [{ provider: "pre-existing-provider", tier: "cheap" }],
|
||||
});
|
||||
|
||||
const getRes = (await route.GET(getRequest())) as Response;
|
||||
assert.equal(getRes.status, 200);
|
||||
const getBody = await getRes.json();
|
||||
assert.ok(Array.isArray(getBody.freeProviders), "should still expose the DEFAULT_TIER_CONFIG shape");
|
||||
assert.deepEqual(getBody.providerOverrides, [{ provider: "pre-existing-provider", tier: "cheap" }]);
|
||||
|
||||
const putRes = (await route.PUT(
|
||||
putRequest({ provider: "my-custom-endpoint-999", tier: "free" })
|
||||
)) as Response;
|
||||
assert.equal(putRes.status, 200, "PUT should round-trip without error against a pre-populated row");
|
||||
const putBody = await putRes.json();
|
||||
assert.deepEqual(putBody.providerOverrides, [
|
||||
{ provider: "pre-existing-provider", tier: "cheap" },
|
||||
{ provider: "my-custom-endpoint-999", tier: "free" },
|
||||
]);
|
||||
});
|
||||
43
tests/unit/tier-coverage-widget-provider-override.test.ts
Normal file
43
tests/unit/tier-coverage-widget-provider-override.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Regression test for #7818 — TierCoverageWidget must honor the same
|
||||
* provider-tier override the router uses, so a tiered custom provider shows
|
||||
* up in the correct bucket instead of always landing in tier2 ("Cheap").
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { classifyConnection } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/TierCoverageWidget.tsx"
|
||||
);
|
||||
|
||||
test("classifyConnection uses the override before falling back to registry membership", () => {
|
||||
// A custom provider id has no NOAUTH/OAUTH registry entry, so without an
|
||||
// override it always falls to tier2 ("Cheap") — the exact gap #7818 reports.
|
||||
assert.equal(classifyConnection("my-custom-endpoint-123", {}), "tier2");
|
||||
|
||||
// With an explicit override, each ProviderTier maps to its widget bucket.
|
||||
assert.equal(
|
||||
classifyConnection("my-custom-endpoint-123", { "my-custom-endpoint-123": "premium" }),
|
||||
"tier1"
|
||||
);
|
||||
assert.equal(
|
||||
classifyConnection("my-custom-endpoint-123", { "my-custom-endpoint-123": "cheap" }),
|
||||
"tier2"
|
||||
);
|
||||
assert.equal(
|
||||
classifyConnection("my-custom-endpoint-123", { "my-custom-endpoint-123": "free" }),
|
||||
"tier3"
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyConnection override lookup is case-insensitive on the provider id", () => {
|
||||
assert.equal(
|
||||
classifyConnection("My-Custom-Endpoint", { "my-custom-endpoint": "free" }),
|
||||
"tier3"
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyConnection falls back to registry membership when no override matches", () => {
|
||||
// openai is a well-known OAuth-registry-less API-key provider -> tier2 by default.
|
||||
assert.equal(classifyConnection("openai", {}), "tier2");
|
||||
});
|
||||
91
tests/unit/tier-resolver-provider-override.test.ts
Normal file
91
tests/unit/tier-resolver-provider-override.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Regression test for #7818 — after a caller sets a provider-tier override
|
||||
* through the new /api/settings/tier-config route, `classifyTier()` must pick
|
||||
* it up immediately (the route calls `setTierConfig()` to bust the in-process
|
||||
* routing cache — this test asserts that cache-bust actually matters, not
|
||||
* just that the override array is stored).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-tier-resolver-override-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/settings/tier-config/route.ts");
|
||||
const tierResolver = await import("../../open-sse/services/tierResolver.ts");
|
||||
|
||||
function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
// classifyTier() caches by provider::model — reset the routing-side config
|
||||
// too so tests don't leak assignments across each other.
|
||||
tierResolver.setTierConfig(null);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function putRequest(body: unknown) {
|
||||
return new Request("http://localhost/api/settings/tier-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test("classifyTier() honors a provider override set via the route, for a custom provider id", async () => {
|
||||
const customProviderId = "my-custom-endpoint-456";
|
||||
const model = "some-model";
|
||||
|
||||
// Before the override: cost-based classification applies (no override yet).
|
||||
const before = tierResolver.classifyTier(customProviderId, model);
|
||||
assert.notEqual(
|
||||
before.reason.includes("Provider-level override"),
|
||||
true,
|
||||
"should not be an override-based assignment before the PUT"
|
||||
);
|
||||
|
||||
const putRes = (await route.PUT(
|
||||
putRequest({ provider: customProviderId, tier: "premium" })
|
||||
)) as Response;
|
||||
assert.equal(putRes.status, 200);
|
||||
|
||||
// After the override: classifyTier() must reflect it on the very next call
|
||||
// for a *different* model (classifyTier caches by provider::model, so we
|
||||
// use a fresh model key to prove the override — not a stale per-key cache
|
||||
// entry — drives the result).
|
||||
const after = tierResolver.classifyTier(customProviderId, `${model}-2`);
|
||||
assert.equal(after.tier, "premium", "classifyTier should honor the newly-set override");
|
||||
assert.ok(
|
||||
after.reason.includes("Provider-level override"),
|
||||
"reason should reflect the provider-level override path"
|
||||
);
|
||||
});
|
||||
|
||||
test("clearing an override via PUT falls back to cost-based classification again", async () => {
|
||||
const customProviderId = "my-custom-endpoint-789";
|
||||
|
||||
await route.PUT(putRequest({ provider: customProviderId, tier: "free" }));
|
||||
const withOverride = tierResolver.classifyTier(customProviderId, "model-a");
|
||||
assert.equal(withOverride.tier, "free");
|
||||
|
||||
await route.PUT(putRequest({ provider: customProviderId, tier: null }));
|
||||
const afterClear = tierResolver.classifyTier(customProviderId, "model-b");
|
||||
assert.notEqual(
|
||||
afterClear.reason.includes("Provider-level override"),
|
||||
true,
|
||||
"after clearing, classification should fall back to cost-based logic"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user