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 && ( handleChange(e.target.value as ProviderTier | "")} + hint={t("tierOverrideHelpText")} + /> + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/providerTierField.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/providerTierField.ts new file mode 100644 index 0000000000..f0753c9eed --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/providerTierField.ts @@ -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(["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 { + 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 { + 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})`); + } +} diff --git a/src/app/api/settings/tier-config/route.ts b/src/app/api/settings/tier-config/route.ts new file mode 100644 index 0000000000..668cc919f2 --- /dev/null +++ b/src/app/api/settings/tier-config/route.ts @@ -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); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1aee10ba13..f7cbe053d1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index c2620de998..3adf48e95e 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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", diff --git a/tests/unit/provider-tier-field-helpers.test.ts b/tests/unit/provider-tier-field-helpers.test.ts new file mode 100644 index 0000000000..53a4ec395d --- /dev/null +++ b/tests/unit/provider-tier-field-helpers.test.ts @@ -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(); + } +}); diff --git a/tests/unit/tier-config-provider-override-route.test.ts b/tests/unit/tier-config-provider-override-route.test.ts new file mode 100644 index 0000000000..93852d6e26 --- /dev/null +++ b/tests/unit/tier-config-provider-override-route.test.ts @@ -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" }, + ]); +}); diff --git a/tests/unit/tier-coverage-widget-provider-override.test.ts b/tests/unit/tier-coverage-widget-provider-override.test.ts new file mode 100644 index 0000000000..0e402c527c --- /dev/null +++ b/tests/unit/tier-coverage-widget-provider-override.test.ts @@ -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"); +}); diff --git a/tests/unit/tier-resolver-provider-override.test.ts b/tests/unit/tier-resolver-provider-override.test.ts new file mode 100644 index 0000000000..295c0e899c --- /dev/null +++ b/tests/unit/tier-resolver-provider-override.test.ts @@ -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" + ); +});