From 431fc02e75afbfee36034017f2285a7d330301f6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:55:16 -0300 Subject: [PATCH] cherry-pick(pr-9569): fix(settings): use provider prefixes in model overrides (#9878) * fix(settings): use provider prefixes in model overrides * refactor(settings): extract pricing tab helpers Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Xiangzhe --- .../ModelCapabilityOverridesTab.tsx | 30 +- .../settings/components/PricingTab.tsx | 102 +-- .../settings/components/PricingTabHelpers.tsx | 56 ++ .../api/model-capability-overrides/route.ts | 156 ++++- src/app/api/pricing/models/route.ts | 41 +- src/lib/modelCapabilityOverrideTargets.ts | 79 +++ src/lib/providerNodePrefixes.ts | 144 +++++ ...del-overrides-provider-prefix-9557.test.ts | 584 ++++++++++++++++++ ...del-capability-overrides-tab-9557.test.tsx | 193 ++++++ 9 files changed, 1265 insertions(+), 120 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/PricingTabHelpers.tsx create mode 100644 src/lib/modelCapabilityOverrideTargets.ts create mode 100644 src/lib/providerNodePrefixes.ts create mode 100644 tests/unit/model-overrides-provider-prefix-9557.test.ts create mode 100644 tests/unit/ui/model-capability-overrides-tab-9557.test.tsx diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx index a4d3c2cb14..4989c397eb 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx @@ -4,28 +4,21 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Card, Button } from "@/shared/components"; import { matchesSearch } from "@/shared/utils/turkishText"; +import { + toModelOverrideTargets, + type PricingCatalogProvider, +} from "@/lib/modelCapabilityOverrideTargets"; type ModelOverrideKey = "context_length" | "max_input_tokens" | "max_output_tokens"; type StatusTone = "success" | "error" | "info"; -type ModelOverrideTarget = { - target: string; - provider: string; - modelId: string; - label: string; -}; +type ModelOverrideTarget = import("@/lib/modelCapabilityOverrideTargets").ModelOverrideTarget; interface PricingCatalogModel { id: string; name: string; } -interface PricingCatalogProvider { - id: string; - alias: string; - models: PricingCatalogModel[]; -} - interface ModelCapabilityOverride { target: string; key: ModelOverrideKey; @@ -120,22 +113,11 @@ function useModelCapabilityOverridesData() { return { catalog, overrides, loading, statusMessage, saveOverride, removeOverride }; } -function toTargets(catalog: Record): ModelOverrideTarget[] { - return Object.values(catalog).flatMap((provider) => - provider.models.map((model) => ({ - target: `${provider.id}/${model.id}`, - provider: provider.id, - modelId: model.id, - label: `${provider.id}/${model.id}`, - })) - ); -} - export default function ModelCapabilityOverridesTab() { const t = useTranslations("settings"); const { catalog, overrides, loading, statusMessage, saveOverride, removeOverride } = useModelCapabilityOverridesData(); - const targets = useMemo(() => toTargets(catalog), [catalog]); + const targets = useMemo(() => toModelOverrideTargets(catalog), [catalog]); if (loading) return
{t("loading")}
; diff --git a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx index ffdf322981..cafd2f45aa 100644 --- a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx @@ -1,5 +1,7 @@ "use client"; +import { FilterSelect, HeroStat, SyncMini } from "./PricingTabHelpers"; + import { useState, useEffect, useCallback, useMemo } from "react"; import { Card, Button } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; @@ -48,6 +50,8 @@ interface PricingCatalogProvider { format: string; modelCount: number; models: PricingCatalogModel[]; + /** Original pricing namespace (e.g. public prefix) when it differs from `alias`. */ + pricingKey?: string; } function getSourceTone(source: PricingSource): string { @@ -133,11 +137,15 @@ export default function PricingTab() { const allProviders = useMemo(() => { return Object.entries(catalog) - .map(([alias, info]) => ({ - ...info, - alias, - pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0, - })) + .map(([alias, info]) => { + const pricingKey = info.pricingKey || alias; + return { + ...info, + alias, + pricingKey, + pricedModels: pricingData[pricingKey] ? Object.keys(pricingData[pricingKey]).length : 0, + }; + }) .sort((left, right) => right.modelCount - left.modelCount); }, [catalog, pricingData]); @@ -312,13 +320,14 @@ export default function PricingTab() { ); const saveProvider = useCallback( - async (providerAlias: string) => { + async (providerAlias: string, pricingKey?: string) => { setSaving(true); try { + const writeKey = pricingKey || providerAlias; const response = await fetch("/api/pricing", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ [providerAlias]: pricingData[providerAlias] || {} }), + body: JSON.stringify({ [writeKey]: pricingData[writeKey] || {} }), }); if (!response.ok) { @@ -328,7 +337,7 @@ export default function PricingTab() { setEditedProviders((previous) => { const next = new Set(previous); - next.delete(providerAlias); + next.delete(writeKey); return next; }); await loadData(); @@ -348,11 +357,13 @@ export default function PricingTab() { ); const resetProvider = useCallback( - async (providerAlias: string) => { + async (providerAlias: string, pricingKey?: string) => { if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return; try { - const response = await fetch(`/api/pricing?provider=${providerAlias}`, { + const writeKey = pricingKey || providerAlias; + const params = new URLSearchParams({ provider: writeKey }); + const response = await fetch(`/api/pricing?${params.toString()}`, { method: "DELETE", }); @@ -363,7 +374,7 @@ export default function PricingTab() { setEditedProviders((previous) => { const next = new Set(previous); - next.delete(providerAlias); + next.delete(writeKey); return next; }); await loadData(); @@ -680,16 +691,16 @@ export default function PricingTab() { toggleProvider(provider.alias)} onPricingChange={(model, field, value) => - handlePricingChange(provider.alias, model, field, value) + handlePricingChange(provider.pricingKey || provider.alias, model, field, value) } - onSave={() => void saveProvider(provider.alias)} - onReset={() => void resetProvider(provider.alias)} + onSave={() => void saveProvider(provider.alias, provider.pricingKey)} + onReset={() => void resetProvider(provider.alias, provider.pricingKey)} saving={saving} getSourceLabel={getSourceLabel} /> @@ -717,63 +728,6 @@ export default function PricingTab() { ); } -function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) { - return ( -
-
- {label} -
-
- {value} -
-
- ); -} - -function SyncMini({ label, value }: { label: string; value: string }) { - return ( -
-

- {label} -

-

- {value} -

-
- ); -} - -function FilterSelect({ - label, - value, - onChange, - options, -}: { - label: string; - value: string; - onChange: (v: string) => void; - options: Array<{ value: string; label: string }>; -}) { - return ( - - ); -} - function ProviderSection({ provider, pricingData, diff --git a/src/app/(dashboard)/dashboard/settings/components/PricingTabHelpers.tsx b/src/app/(dashboard)/dashboard/settings/components/PricingTabHelpers.tsx new file mode 100644 index 0000000000..8dd99258c9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/PricingTabHelpers.tsx @@ -0,0 +1,56 @@ +export function HeroStat({ label, value, accent }: { label: string; value: number; accent?: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +export function SyncMini({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +export function FilterSelect({ + label, + value, + onChange, + options, +}: { + label: string; + value: string; + onChange: (value: string) => void; + options: Array<{ value: string; label: string }>; +}) { + return ( + + ); +} diff --git a/src/app/api/model-capability-overrides/route.ts b/src/app/api/model-capability-overrides/route.ts index 0cb73d7a4f..a5673df4d5 100644 --- a/src/app/api/model-capability-overrides/route.ts +++ b/src/app/api/model-capability-overrides/route.ts @@ -14,12 +14,39 @@ import { removeModelContextOverride, setModelContextOverride, } from "@/lib/db/modelContextOverrides"; +import { getProviderPrefixIndex, type ProviderPrefixEntry } from "@/lib/providerNodePrefixes"; const overrideKeySchema = z.enum(["context_length", "max_input_tokens", "max_output_tokens"]); type PublicOverrideKey = z.infer; type PublicOverride = Omit & { key: PublicOverrideKey }; -function listPublicOverrides(): PublicOverride[] { +/** + * One-time per-request snapshot of the provider-node prefix index. Loaded once + * per handler (never N times per row) straight from the DB — no module-global + * mutable caches, no route-to-route imports. + */ +async function loadPrefixMaps(): Promise<{ + entries: Map; + nodeToPrefix: Map; + prefixToNode: Map; + eligibleNodeIds: Set; + compatibleNodeIds: Set; +}> { + const index = await getProviderPrefixIndex(); + return { + entries: index.entries, + nodeToPrefix: index.nodeToPrefix, + prefixToNode: index.prefixToNode, + eligibleNodeIds: index.eligibleNodeIds, + compatibleNodeIds: index.compatibleNodeIds, + }; +} + +async function listPublicOverrides( + nodeToPrefix: Map, + eligibleNodeIds: Set, + compatibleNodeIds: Set +): Promise { const capabilityOverrides = listModelCapabilityOverrides() as PublicOverride[]; const contextOverrides = listModelContextOverrides().map((override): PublicOverride => ({ provider: override.provider, @@ -29,9 +56,27 @@ function listPublicOverrides(): PublicOverride[] { value: override.realContext, refreshedAt: override.refreshedAt, })); - return [...capabilityOverrides, ...contextOverrides].sort((left, right) => - right.refreshedAt.localeCompare(left.refreshedAt) - ); + const merged = [...capabilityOverrides, ...contextOverrides]; + return merged + .filter((override) => { + // A compatible node that is NOT the unique non-reserved prefix winner is + // ineligible for Model Overrides: never surface it under a generated node + // UUID. Eligible winners are those in `eligibleNodeIds` (routable via + // their public prefix); built-in providers (not in `compatibleNodeIds`) + // are always eligible. + return !compatibleNodeIds.has(override.provider) || eligibleNodeIds.has(override.provider); + }) + .map((override) => { + const displayProvider = nodeToPrefix.get(override.provider) || override.provider; + return { + ...override, + // Both `provider` and `target` are exposed under the public prefix so no + // generated node UUID ever leaks into the JSON for a prefixed node. + provider: displayProvider, + target: `${displayProvider}/${override.modelId}`, + }; + }) + .sort((left, right) => right.refreshedAt.localeCompare(left.refreshedAt)); } const upsertOverrideSchema = z.object({ @@ -40,23 +85,65 @@ const upsertOverrideSchema = z.object({ value: z.coerce.number().int().positive(), }); -function canonicalizeTarget(target: string): string | null { +/** + * Canonicalize a public `/` target to `/` + * so the override is stored where runtime lookup reads it. Mirrors runtime + * prefix routing exactly: + * + * - `unique` configured prefix → canonicalize to the single runtime-routable + * winner node (first openai-compatible then anthropic-compatible, by id). + * - `reserved` configured prefix (collides with a built-in registry id/alias, + * e.g. a node with `prefix="cx"`) → route via `resolveProviderAlias` to the + * built-in canonical provider (runtime routes `cx/` to codex), never 400. + * - `ambiguous` (no runtime winner selectable) → fail closed (400). + * - A bare built-in alias/id that is NOT a configured node prefix (e.g. + * `openai` typed directly) resolves via `resolveProviderAlias` (unchanged). + * + * Returns the canonical `provider/model` on success, or `{ ok: false }`. + */ +function canonicalizeTarget( + target: string, + entries: Map, + prefixToNode: Map, + compatibleNodeIds: Set, + eligibleNodeIds: Set +): { ok: true; target: string } | { ok: false } { const raw = target.trim(); const slashIndex = raw.indexOf("/"); - if (slashIndex <= 0 || slashIndex === raw.length - 1) return null; + if (slashIndex <= 0 || slashIndex === raw.length - 1) return { ok: false }; const provider = raw.slice(0, slashIndex).trim(); const modelId = raw.slice(slashIndex + 1).trim(); - if (!provider || !modelId) return null; + if (!provider || !modelId) return { ok: false }; - return `${resolveProviderAlias(provider) || provider}/${modelId}`; + // Raw internal compatible-node IDs are never a public Model Overrides target. + // Only the public prefix of an eligible runtime winner may select a node. + // Reject ineligible raw IDs as well as eligible raw IDs so stale or direct API + // callers cannot create UUID-keyed overrides that the UI cannot manage. + if (compatibleNodeIds.has(provider)) return { ok: false }; + + const configured = entries.get(provider); + // A reserved configured prefix is routable to the built-in canonical provider + // (runtime never routes it to the compatible node). `resolveProviderAlias` + // maps e.g. `cx` → `codex`. Only an ambiguous prefix has no routable target. + if (configured && configured.status === "ambiguous") { + return { ok: false }; + } + const resolvedNodeId = prefixToNode.get(provider); + if (resolvedNodeId && !eligibleNodeIds.has(resolvedNodeId)) return { ok: false }; + const canonicalProvider = resolvedNodeId || resolveProviderAlias(provider) || provider; + + return { ok: true, target: `${canonicalProvider}/${modelId}` }; } export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - return NextResponse.json({ overrides: listPublicOverrides() }); + const { nodeToPrefix, eligibleNodeIds, compatibleNodeIds } = await loadPrefixMaps(); + return NextResponse.json({ + overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds), + }); } export async function PATCH(request: Request) { @@ -75,17 +162,28 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: parsed.error.issues }, { status: 400 }); } - const target = canonicalizeTarget(parsed.data.target); - if (!target) { - return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 }); + const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } = + await loadPrefixMaps(); + const canonical = canonicalizeTarget( + parsed.data.target, + entries, + prefixToNode, + compatibleNodeIds, + eligibleNodeIds + ); + if (!canonical.ok) { + return NextResponse.json( + { error: "Invalid or ambiguous model capability override target" }, + { status: 400 } + ); } - const targetParts = target.split(/\/(.*)/s); + const targetParts = canonical.target.split(/\/(.*)/s); const written = parsed.data.key === "context_length" ? setModelContextOverride(targetParts[0], targetParts[1], parsed.data.value, "manual") : setModelCapabilityOverride( - target, + canonical.target, parsed.data.key as ModelCapabilityOverrideKey, parsed.data.value ); @@ -93,7 +191,9 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: "Invalid model capability override" }, { status: 400 }); } - return NextResponse.json({ overrides: listPublicOverrides() }); + return NextResponse.json({ + overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds), + }); } export async function DELETE(request: Request) { @@ -101,19 +201,33 @@ export async function DELETE(request: Request) { if (authError) return authError; const { searchParams } = new URL(request.url); - const target = canonicalizeTarget(searchParams.get("target") || ""); const key = searchParams.get("key") || ""; const parsedKey = overrideKeySchema.safeParse(key); - if (!target || !parsedKey.success) { - return NextResponse.json({ error: "target and key are required" }, { status: 400 }); + const { entries, nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } = + await loadPrefixMaps(); + const canonical = canonicalizeTarget( + searchParams.get("target") || "", + entries, + prefixToNode, + compatibleNodeIds, + eligibleNodeIds + ); + + if (!canonical.ok || !parsedKey.success) { + return NextResponse.json( + { error: "target and key are required; target must be a valid model override target" }, + { status: 400 } + ); } if (parsedKey.data === "context_length") { - const targetParts = target.split(/\/(.*)/s); + const targetParts = canonical.target.split(/\/(.*)/s); removeModelContextOverride(targetParts[0], targetParts[1]); } else { - removeModelCapabilityOverride(target, parsedKey.data as ModelCapabilityOverrideKey); + removeModelCapabilityOverride(canonical.target, parsedKey.data as ModelCapabilityOverrideKey); } - return NextResponse.json({ overrides: listPublicOverrides() }); + return NextResponse.json({ + overrides: await listPublicOverrides(nodeToPrefix, eligibleNodeIds, compatibleNodeIds), + }); } diff --git a/src/app/api/pricing/models/route.ts b/src/app/api/pricing/models/route.ts index e25ba2fcc1..aab92ba3eb 100644 --- a/src/app/api/pricing/models/route.ts +++ b/src/app/api/pricing/models/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb"; +import { getProviderPrefixIndex } from "@/lib/providerNodePrefixes"; function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -28,6 +29,12 @@ export async function GET() { try { const catalog: Record = {}; + // Pre-load compatible-provider node public prefixes once (shared across the + // whole catalog build — never N lookups per model). Only uniquely-routable + // prefixes are exposed as public targets (reserved/ambiguous are not). + const { nodeToPrefix, prefixToNode, eligibleNodeIds, compatibleNodeIds } = + await getProviderPrefixIndex(); + // ── 1. Registry models (hardcoded) ────────────────────────────── for (const entry of Object.values(REGISTRY)) { const alias = entry.alias || entry.id; @@ -54,6 +61,13 @@ export async function GET() { return providerId; }; + // A compatible provider node should surface under its configured public + // prefix, never its generated `openai-compatible-chat-` node id + // (#9557). The internal `id` (node id) is preserved for PricingTab and + // runtime capability lookup. Only a uniquely-routable non-reserved winner + // is Model-Overrides eligible (marked explicitly); a compatible node that + // is reserved/losing/no-prefix is marked ineligible and skipped by the + // Model-Overrides helper. const ensureCatalogProvider = (providerId: string, alias: string) => { if (!catalog[alias]) { catalog[alias] = { @@ -64,6 +78,11 @@ export async function GET() { format: "openai", models: [], }; + const prefix = nodeToPrefix.get(providerId); + if (prefix) catalog[alias].displayPrefix = prefix; + if (compatibleNodeIds.has(providerId)) { + catalog[alias].modelOverrideEligible = eligibleNodeIds.has(providerId); + } } return catalog[alias]; }; @@ -111,6 +130,13 @@ export async function GET() { } // ── 4. Pricing-only models (DB) ───────────────────────────────── + // Pricing may be keyed by the node's public prefix (what the operator typed) + // or by the internal node id. When keyed by a uniquely-routable public + // prefix, reconcile it to that node so the model list merges into the + // canonical compatible-provider entry instead of duplicating it, and + // preserve the original pricing namespace as `pricingKey` so PricingTab can + // read/save/reset against it. Reserved / ambiguous prefixes have no single + // routable node and stay as-is. let pricingData: Record = {}; try { pricingData = await getPricing(); @@ -118,7 +144,10 @@ export async function GET() { /* DB may not be ready */ } - for (const [providerAlias, models] of Object.entries(pricingData)) { + for (const [rawProviderAlias, models] of Object.entries(pricingData)) { + // `rawProviderAlias` is the original pricing namespace the operator used. + const pricingKey = rawProviderAlias; + const providerAlias = prefixToNode.get(rawProviderAlias) || rawProviderAlias; if (!catalog[providerAlias]) { catalog[providerAlias] = { id: providerAlias, @@ -128,6 +157,16 @@ export async function GET() { format: "openai", models: [], }; + const prefix = nodeToPrefix.get(providerAlias); + if (prefix) catalog[providerAlias].displayPrefix = prefix; + if (compatibleNodeIds.has(providerAlias)) { + catalog[providerAlias].modelOverrideEligible = eligibleNodeIds.has(providerAlias); + } + } + // When the entry is keyed internally by the node id but priced under a + // public prefix, remember the original pricing namespace for PricingTab. + if (pricingKey !== providerAlias && !catalog[providerAlias].pricingKey) { + catalog[providerAlias].pricingKey = pricingKey; } const existingIds = new Set(catalog[providerAlias].models.map((m) => m.id)); diff --git a/src/lib/modelCapabilityOverrideTargets.ts b/src/lib/modelCapabilityOverrideTargets.ts new file mode 100644 index 0000000000..7d97abb076 --- /dev/null +++ b/src/lib/modelCapabilityOverrideTargets.ts @@ -0,0 +1,79 @@ +/** + * Pure catalog → Model Override target conversion. + * + * The operator-facing Model Overrides surface must present a compatible + * provider node under its configured public `prefix` (e.g. `vibeproxy/gpt-4o`) + * — never the generated `openai-compatible-chat-` node id (#9557). + * + * The pricing catalog keeps the internal `id` (the DB node id, which PricingTab + * uses to key pricing data) and, when the node has a configured prefix, also + * carries `displayPrefix`. This helper prefers `displayPrefix` for the public + * label/target while leaving the raw id untouched for storage/runtime lookup. + * + * Model-Overrides eligibility seam: a compatible provider node is eligible only + * when it is the unique, non-reserved runtime-routable winner of its configured + * prefix. The catalog marks such winners with `modelOverrideEligible === true` + * (and a `displayPrefix`); reserved/losing/no-public-prefix compatible nodes are + * marked `modelOverrideEligible === false` and are SKIPPED — never surfaced + * under a generated node UUID. Built-in / no-compatible catalog entries carry no + * flag and remain targetable. + */ + +export interface PricingCatalogModel { + id: string; + name: string; +} + +export interface PricingCatalogProvider { + id: string; + alias: string; + displayPrefix?: string; + /** Explicit Model-Overrides eligibility; undefined ⇒ eligible (built-in/no-compatible). */ + modelOverrideEligible?: boolean; + models: PricingCatalogModel[]; +} + +export interface ModelOverrideTarget { + target: string; + provider: string; + modelId: string; + label: string; +} + +/** + * Whether a catalog provider is targetable in Model Overrides. Only compatible + * nodes marked ineligible (reserved/losing/no-public-prefix) are skipped; all + * built-in and no-compatible entries are eligible. + */ +export function isModelOverrideEligible(provider: PricingCatalogProvider): boolean { + return provider.modelOverrideEligible !== false; +} + +/** + * Public display prefix for a compatible provider node, falling back to its + * internal id when no operator-configured prefix is set. + */ +export function modelOverrideProviderPrefix(provider: PricingCatalogProvider): string { + return provider.displayPrefix?.trim() || provider.id; +} + +/** + * Convert the /api/pricing/models catalog into Model Override targets. Each + * target uses the node's public prefix (when configured) so the selector, + * search, selected model, and the target sent to the override API never expose + * a generated node UUID. Ineligible compatible nodes are skipped entirely. + */ +export function toModelOverrideTargets( + catalog: Record +): ModelOverrideTarget[] { + return Object.values(catalog).flatMap((provider) => { + if (!isModelOverrideEligible(provider)) return []; + const prefix = modelOverrideProviderPrefix(provider); + return provider.models.map((model) => ({ + target: `${prefix}/${model.id}`, + provider: prefix, + modelId: model.id, + label: `${prefix}/${model.id}`, + })); + }); +} diff --git a/src/lib/providerNodePrefixes.ts b/src/lib/providerNodePrefixes.ts new file mode 100644 index 0000000000..83a4fbb623 --- /dev/null +++ b/src/lib/providerNodePrefixes.ts @@ -0,0 +1,144 @@ +/** + * Shared provider-node public-prefix index (#9557). + * + * A compatible provider node (openai/anthropic-compatible) can carry an + * operator-configured public `prefix` (e.g. `vibeproxy`) that the Model + * Overrides surface must expose instead of the generated + * `openai-compatible-chat-` node id. + * + * This module is the single narrow home for that index so both the pricing + * catalog route and the override route resolve node → prefix / prefix → node + * consistently. It does one `getProviderNodes()` DB read per call and derives + * every map from it — no module-global mutable caches, no route-to-route + * imports. + * + * Classification of each configured prefix (mirrors runtime semantics): + * - `reserved`: the prefix collides with a built-in registry id/alias + * (e.g. `cx` → codex). Such a node must NOT be advertised as a compatible + * public target and the prefix must never be canonicalized to that node — + * runtime routes reserved prefixes to the built-in provider, so the + * override route must too. + * - `unique`: a single runtime-routable node owns the prefix. When two or + * more nodes share a prefix, the runtime winner is deterministic (first + * openai-compatible node by id order, else first anthropic-compatible + * node — see `getModelInfo`), and the prefix index selects that same + * winner. Only the winner is targetable/displayed under the prefix; + * losing nodes are ineligible and never fall back to a node UUID target. + * - `ambiguous`: multiple nodes share the prefix but no runtime winner is + * selectable (no compatible node matches) — practically unreachable since + * only compatible nodes carry prefixes, kept for safety. + * + * Model-Overrides eligibility: a compatible node is eligible only when it is + * the unique, non-reserved winner of its configured prefix (i.e. it is in + * `eligibleNodeIds`). Reserved/losing/no-public-prefix compatible nodes are + * ineligible and must be skipped — never surfaced under a generated node UUID. + * Built-in/no-compatible catalog entries are always eligible. + */ + +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { getProviderNodes } from "@/lib/db/providers/nodes"; + +export type ProviderPrefixStatus = "unique" | "ambiguous" | "reserved"; + +export interface ProviderPrefixEntry { + prefix: string; + status: ProviderPrefixStatus; + /** Present only when `status === "unique"`. */ + nodeId?: string; +} + +export interface ProviderPrefixIndex { + /** prefix → classification entry (every configured prefix). */ + entries: Map; + /** nodeId → public prefix, only for uniquely-routable non-reserved winners. */ + nodeToPrefix: Map; + /** public prefix → nodeId, only for uniquely-routable non-reserved winners. */ + prefixToNode: Map; + /** Every compatible provider node id present in the node table. */ + compatibleNodeIds: Set; + /** Compatible node ids eligible for Model Overrides (unique non-reserved winners). */ + eligibleNodeIds: Set; +} + +/** + * Built-in reserved prefixes — registry ids + aliases, the same semantics the + * runtime `getReservedProviderPrefixes()` uses so user-defined compatible-node + * prefixes can never shadow a built-in provider. + */ +export function buildReservedPrefixes(): Set { + const reserved = new Set(); + for (const entry of Object.values(REGISTRY)) { + if (entry?.id) reserved.add(entry.id); + if (entry?.alias) reserved.add(entry.alias); + } + return reserved; +} + +export interface CompatibleNodeLike { + id?: string; + type?: string; + prefix?: string | null; +} + +/** + * Pure prefix→node winner selection replicating the runtime `getModelInfo` + * rule exactly: the first openai-compatible node (by DB/id order) whose + * `prefix` matches wins; otherwise the first anthropic-compatible node. + * `nodes` must already be in the runtime's id-ascending order (as + * `getProviderNodes` returns). + */ +export function selectCompatibleNodeForPrefix( + nodes: CompatibleNodeLike[], + prefix: string +): CompatibleNodeLike | null { + const openaiMatch = nodes.find((n) => n.type === "openai-compatible" && n.prefix === prefix); + if (openaiMatch) return openaiMatch; + return nodes.find((n) => n.type === "anthropic-compatible" && n.prefix === prefix) ?? null; +} + +export async function getProviderPrefixIndex(): Promise { + const reserved = buildReservedPrefixes(); + const nodes = (await getProviderNodes()) as CompatibleNodeLike[]; + const compatible = nodes.filter( + (n) => n.type === "openai-compatible" || n.type === "anthropic-compatible" + ); + + const compatibleNodeIds = new Set(); + for (const node of compatible) { + if (node.id) compatibleNodeIds.add(node.id); + } + + const byPrefix = new Map(); + for (const node of compatible) { + const prefix = node.prefix?.trim(); + if (!node.id || !prefix) continue; + const list = byPrefix.get(prefix) ?? []; + list.push(node); + byPrefix.set(prefix, list); + } + + const entries = new Map(); + const nodeToPrefix = new Map(); + const prefixToNode = new Map(); + const eligibleNodeIds = new Set(); + + for (const [prefix, prefixNodes] of byPrefix) { + if (reserved.has(prefix)) { + // Built-in registry id/alias — never a compatible public target. + entries.set(prefix, { prefix, status: "reserved" }); + continue; + } + const winner = selectCompatibleNodeForPrefix(prefixNodes, prefix); + if (!winner?.id) { + entries.set(prefix, { prefix, status: "ambiguous" }); + continue; + } + // The runtime-routable winner alone owns the prefix. + entries.set(prefix, { prefix, status: "unique", nodeId: winner.id }); + nodeToPrefix.set(winner.id, prefix); + prefixToNode.set(prefix, winner.id); + eligibleNodeIds.add(winner.id); + } + + return { entries, nodeToPrefix, prefixToNode, compatibleNodeIds, eligibleNodeIds }; +} diff --git a/tests/unit/model-overrides-provider-prefix-9557.test.ts b/tests/unit/model-overrides-provider-prefix-9557.test.ts new file mode 100644 index 0000000000..a78bb6b7ad --- /dev/null +++ b/tests/unit/model-overrides-provider-prefix-9557.test.ts @@ -0,0 +1,584 @@ +import { describe, it, beforeEach, after } 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 moduleDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-model-overrides-prefix-")); +process.env.DATA_DIR = moduleDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const nodes = await import("../../src/lib/db/providers/nodes.ts"); +const models = await import("../../src/lib/db/models.ts"); +const overrides = await import("../../src/lib/db/modelCapabilityOverrides.ts"); +const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts"); +const pricingRoute = await import("../../src/app/api/pricing/models/route.ts"); +const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts"); +const targets = await import("../../src/lib/modelCapabilityOverrideTargets.ts"); +const caps = await import("../../src/lib/modelCapabilities.ts"); +const prefixIndex = await import("../../src/lib/providerNodePrefixes.ts"); +// Runtime prefix→node resolution (same path the request pipeline uses). +const sseModel = await import("../../src/sse/services/model.ts"); + +beforeEach(() => { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); + fs.mkdirSync(moduleDataDir, { recursive: true }); + coreDb.getDbInstance(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); +}); + +const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"; +const NODE_PREFIX = "vibeproxy"; +const NODE_TYPE = "openai-compatible"; + +async function seedNodeWithSyncedModel(modelId = "gpt-4o", opts: { prefix?: string | null } = {}) { + await nodes.createProviderNode({ + id: NODE_ID, + type: NODE_TYPE, + prefix: opts.prefix === undefined ? NODE_PREFIX : opts.prefix, + name: "VibeProxy", + apiType: "chat", + baseUrl: "https://example.com/v1", + }); + await models.replaceSyncedAvailableModelsForConnection(NODE_ID, NODE_ID, [ + { id: modelId, name: modelId }, + ]); +} + +describe("issue #9557: model overrides expose configured provider prefix, not node UUID", () => { + it("pricing/models returns public displayPrefix while retaining internal node id", async () => { + await seedNodeWithSyncedModel(); + const response = await pricingRoute.GET(); + assert.equal(response.status, 200); + const catalog = (await response.json()) as Record< + string, + { id: string; alias: string; displayPrefix?: string; models: Array<{ id: string }> } + >; + + const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID); + assert.ok(entry, "compatible node must appear in the pricing catalog"); + assert.equal(entry.id, NODE_ID, "internal node id must be preserved for PricingTab"); + assert.equal(entry.displayPrefix, NODE_PREFIX, "public display prefix must be exposed"); + assert.ok( + entry.models.some((model) => model.id === "gpt-4o"), + "synced model must be listed under the compatible node" + ); + }); + + it("toModelOverrideTargets labels/selects with the public prefix, never the node UUID", () => { + const catalog = { + [NODE_ID]: { + id: NODE_ID, + alias: NODE_ID, + displayPrefix: NODE_PREFIX, + models: [{ id: "gpt-4o", name: "gpt-4o" }], + }, + openai: { + id: "openai", + alias: "openai", + models: [{ id: "gpt-4o", name: "gpt-4o" }], + }, + // A losing/reserved compatible node explicitly marked ineligible must be + // skipped entirely — never surfaced under a node UUID. + "openai-compatible-chat-loser": { + id: "openai-compatible-chat-loser", + alias: "openai-compatible-chat-loser", + displayPrefix: NODE_PREFIX, + modelOverrideEligible: false, + models: [{ id: "lost-model", name: "lost-model" }], + }, + }; + const result = targets.toModelOverrideTargets(catalog); + const [compatible, builtin] = result; + + assert.equal(result.length, 2, "ineligible compatible node is skipped"); + assert.equal(compatible.target, `${NODE_PREFIX}/gpt-4o`); + assert.equal(compatible.provider, NODE_PREFIX); + assert.equal(compatible.label, `${NODE_PREFIX}/gpt-4o`); + assert.ok(!compatible.target.includes(NODE_ID), "public target must not leak the node UUID"); + assert.ok(!result.some((t) => t.target.includes("lost-model")), "lost node not a target"); + assert.equal(builtin.target, "openai/gpt-4o"); + assert.equal( + targets.isModelOverrideEligible(catalog[NODE_ID]), + true, + "unflagged compatible winner is eligible" + ); + assert.equal( + targets.isModelOverrideEligible(catalog["openai-compatible-chat-loser"]), + false, + "explicitly ineligible node is excluded" + ); + assert.equal(targets.isModelOverrideEligible(catalog.openai), true, "built-in is eligible"); + }); + + it("PATCH canonicalizes a configured prefix to the internal node id and runtime lookup applies it", async () => { + await seedNodeWithSyncedModel("gpt-4o"); + + const patch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${NODE_PREFIX}/gpt-4o`, + key: "max_output_tokens", + value: 123456, + }), + }) + ); + assert.equal(patch.status, 200); + + // Persisted under the internal node id (where runtime lookup reads it). + const stored = overrides.listModelCapabilityOverrides(); + assert.equal(stored.length, 1); + assert.equal(stored[0].provider, NODE_ID); + assert.equal(stored[0].modelId, "gpt-4o"); + + // Runtime capability lookup resolves provider/model and applies the override. + const resolved = caps.getResolvedModelCapabilities({ + provider: NODE_ID, + model: "gpt-4o", + }); + assert.equal(resolved.maxOutputTokens, 123456); + const explicit = caps.getExplicitModelOutputCap({ provider: NODE_ID, model: "gpt-4o" }); + assert.equal(explicit, 123456); + }); + + it("runtime getModelInfo resolves prefix/model back to the node id (routing seam)", async () => { + await seedNodeWithSyncedModel("gpt-4o"); + + const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`); + assert.ok(info, "getModelInfo must resolve"); + assert.equal(info.provider, NODE_ID, "prefix must route to the internal node id at runtime"); + }); + + it("index winner matches runtime getModelInfo for a duplicated prefix", async () => { + // Two nodes share the prefix. Runtime resolves the FIRST openai-compatible + // node by id order. The prefix index must select that same winner so the + // UI and PATCH agree with the routing seam. + const nodeAId = NODE_ID; + const nodeBId = "openai-compatible-chat-22222222-2222-4333-8444-555555555555"; + await nodes.createProviderNode({ + id: nodeBId, + type: NODE_TYPE, + prefix: NODE_PREFIX, + name: "VibeProxy B", + apiType: "chat", + baseUrl: "https://example.com/b/v1", + }); + await nodes.createProviderNode({ + id: nodeAId, + type: NODE_TYPE, + prefix: NODE_PREFIX, + name: "VibeProxy A", + apiType: "chat", + baseUrl: "https://example.com/a/v1", + }); + await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [ + { id: "gpt-4o", name: "gpt-4o" }, + ]); + + const info = await sseModel.getModelInfo(`${NODE_PREFIX}/gpt-4o`); + assert.equal(info.provider, nodeAId, "runtime resolves the first node by id"); + + const index = await prefixIndex.getProviderPrefixIndex(); + assert.equal( + index.prefixToNode.get(NODE_PREFIX), + info.provider, + "index prefix→node must match the runtime winner" + ); + assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, info.provider); + assert.ok(index.eligibleNodeIds.has(info.provider)); + assert.ok(!index.eligibleNodeIds.has(nodeBId)); + + // And PATCH persists to the same winner runtime resolves to. + const patch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${NODE_PREFIX}/gpt-4o`, + key: "max_input_tokens", + value: 77777, + }), + }) + ); + assert.equal(patch.status, 200); + assert.equal(overrides.listModelCapabilityOverrides()[0].provider, info.provider); + }); + + it("GET surfaces stored overrides under the public prefix and old raw rows still list/apply", async () => { + await seedNodeWithSyncedModel("gpt-4o"); + + // New-style row saved via the API (canonicalized to node id). + await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${NODE_PREFIX}/gpt-4o`, + key: "max_input_tokens", + value: 99999, + }), + }) + ); + + // Old raw-UUID-keyed row inserted directly (legacy pre-#9557 data). + assert.equal( + overrides.setModelCapabilityOverride(`${NODE_ID}/gpt-4o`, "max_output_tokens", 55555), + true + ); + + const get = await overrideRoute.GET( + new Request("http://localhost/api/model-capability-overrides") + ); + assert.equal(get.status, 200); + const payload = (await get.json()) as { + overrides: Array<{ target: string; key: string; value: number }>; + }; + const targetsFound = payload.overrides.map((entry) => entry.target).sort(); + assert.deepEqual(targetsFound, [`${NODE_PREFIX}/gpt-4o`, `${NODE_PREFIX}/gpt-4o`]); + assert.ok( + payload.overrides.every((entry) => !entry.target.includes(NODE_ID)), + "public override list must not leak the node UUID" + ); + + // Old raw row still applies at runtime. + assert.equal( + caps.getResolvedModelCapabilities({ provider: NODE_ID, model: "gpt-4o" }).maxOutputTokens, + 55555 + ); + }); + + it("GET/PATCH/DELETE JSON responses never leak the node UUID for a prefixed node", async () => { + await seedNodeWithSyncedModel("gpt-4o"); + await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${NODE_PREFIX}/gpt-4o`, + key: "max_output_tokens", + value: 333, + }), + }) + ); + + const get = await overrideRoute.GET( + new Request("http://localhost/api/model-capability-overrides") + ); + assert.ok(!(await get.text()).includes(NODE_ID), "GET body must not contain node UUID"); + + const patch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${NODE_PREFIX}/gpt-4o`, + key: "max_input_tokens", + value: 444, + }), + }) + ); + assert.ok(!(await patch.text()).includes(NODE_ID), "PATCH body must not contain node UUID"); + + const del = await overrideRoute.DELETE( + new Request( + `http://localhost/api/model-capability-overrides?target=${NODE_PREFIX}/gpt-4o&key=max_output_tokens`, + { method: "DELETE" } + ) + ); + assert.equal(del.status, 200); + const delBody = await del.text(); + assert.ok(!delBody.includes(NODE_ID), "DELETE body must not contain node UUID"); + // DELETE still returns the updated override list for the UI. + const delPayload = JSON.parse(delBody) as { overrides: unknown[] }; + assert.ok(Array.isArray(delPayload.overrides), "DELETE returns { overrides }"); + assert.equal(delPayload.overrides.length, 1, "remaining override still present"); + }); + + it("duplicate prefix selects the same runtime winner; losing node never falls back to UUID", async () => { + // Two nodes share the same prefix → the runtime winner is the FIRST + // openai-compatible node by id order. The index and PATCH must choose that + // same winner; the losing node must NOT be targetable/displayed under the + // prefix and must NOT fall back to a node-UUID target. + const nodeAId = NODE_ID; + const nodeBId = "openai-compatible-chat-11111111-2222-4333-8444-555555555555"; + await nodes.createProviderNode({ + id: nodeBId, + type: NODE_TYPE, + prefix: NODE_PREFIX, + name: "VibeProxy B", + apiType: "chat", + baseUrl: "https://example.com/b/v1", + }); + await nodes.createProviderNode({ + id: nodeAId, + type: NODE_TYPE, + prefix: NODE_PREFIX, + name: "VibeProxy A", + apiType: "chat", + baseUrl: "https://example.com/a/v1", + }); + await models.replaceSyncedAvailableModelsForConnection(nodeAId, nodeAId, [ + { id: "gpt-4o", name: "gpt-4o" }, + ]); + + const index = await prefixIndex.getProviderPrefixIndex(); + // Runtime resolves the first openai-compatible node by id order → nodeAId. + assert.equal(index.entries.get(NODE_PREFIX)?.status, "unique"); + assert.equal(index.entries.get(NODE_PREFIX)?.nodeId, nodeAId, "winner is first by id"); + assert.equal(index.prefixToNode.get(NODE_PREFIX), nodeAId); + assert.equal(index.nodeToPrefix.get(nodeAId), NODE_PREFIX); + // Losing node is ineligible — no node-UUID target, no prefix→node mapping. + assert.ok(!index.eligibleNodeIds.has(nodeBId), "losing node is ineligible"); + assert.ok(!index.prefixToNode.has(nodeBId), "losing node id must not be a prefix target"); + assert.ok(!index.nodeToPrefix.has(nodeBId), "losing node must not map to the shared prefix"); + + // Catalog advertises only the winner under the public prefix. + const catalogRes = await pricingRoute.GET(); + const catalog = (await catalogRes.json()) as Record< + string, + { id: string; displayPrefix?: string } + >; + const winner = Object.values(catalog).find((p) => p.id === nodeAId); + assert.equal(winner?.displayPrefix, NODE_PREFIX, "winner advertised under prefix"); + assert.ok( + !Object.values(catalog).some((p) => p.id === nodeBId && p.displayPrefix === NODE_PREFIX), + "losing node must not be advertised under the shared prefix" + ); + + // PATCH via the prefix canonicalizes to the winner (first by id) and the + // losing node is not targetable. + const patch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${NODE_PREFIX}/gpt-4o`, + key: "max_output_tokens", + value: 123456, + }), + }) + ); + assert.equal(patch.status, 200); + const stored = overrides.listModelCapabilityOverrides(); + assert.equal(stored.length, 1); + assert.equal(stored[0].provider, nodeAId, "PATCH must persist to the runtime winner"); + + // Raw compatible-node UUIDs are not public Model Overrides targets. The + // losing node remains routable internally, but stale/direct clients must + // use the configured public prefix rather than create an unmanageable row. + const losePatch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${nodeBId}/gpt-4o`, + key: "max_input_tokens", + value: 7, + }), + }) + ); + assert.equal(losePatch.status, 400); + const loseDelete = await overrideRoute.DELETE( + new Request( + `http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${nodeBId}/gpt-4o`)}&key=max_input_tokens`, + { method: "DELETE" } + ) + ); + assert.equal(loseDelete.status, 400); + assert.equal(overrides.listModelCapabilityOverrides().length, 1); + }); + + it("reserved prefix (cx → codex) is not advertised and not canonicalized to any node", async () => { + const RESERVED = "cx"; + const reservedNodeId = "openai-compatible-chat-99999999-2222-4333-8444-555555555555"; + await nodes.createProviderNode({ + id: reservedNodeId, + type: NODE_TYPE, + prefix: RESERVED, + name: "Reserved Hijack", + apiType: "chat", + baseUrl: "https://example.com/cx/v1", + }); + + const index = await prefixIndex.getProviderPrefixIndex(); + assert.equal(index.entries.get(RESERVED)?.status, "reserved"); + assert.ok( + !index.nodeToPrefix.has(reservedNodeId), + "reserved-prefix node must not be exposed as a compatible target" + ); + assert.ok(!index.prefixToNode.has(RESERVED), "reserved prefix must not map to any node"); + + // Catalog: reserved prefix must not surface as a displayPrefix on any entry. + const catalogRes = await pricingRoute.GET(); + const catalog = (await catalogRes.json()) as Record< + string, + { displayPrefix?: string; id?: string } + >; + assert.ok( + !Object.values(catalog).some((provider) => provider.displayPrefix === RESERVED), + "reserved prefix must not be advertised as a compatible public target" + ); + + // PATCH using `cx/` must route via resolveProviderAlias to the + // built-in codex provider (runtime routes `cx/` to codex) — never 400, and + // never persisted under the reserved-hijack node. + const patch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${RESERVED}/gpt-4o`, + key: "max_output_tokens", + value: 1, + }), + }) + ); + assert.equal(patch.status, 200, "PATCH must route reserved prefix via resolveProviderAlias"); + + const stored = overrides.listModelCapabilityOverrides(); + assert.equal( + stored.length, + 1, + "reserved prefix override is persisted to the built-in provider" + ); + assert.notEqual(stored[0].provider, reservedNodeId, "must never persist to the hijack node"); + assert.equal(stored[0].provider, "codex", "reserved prefix must resolve to built-in codex"); + + const rawPatch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${reservedNodeId}/gpt-4o`, + key: "max_input_tokens", + value: 2, + }), + }) + ); + assert.equal(rawPatch.status, 400, "reserved node UUID must not be writable directly"); + }); + + it("pricing-only compatible-provider model entry carries displayPrefix + pricingKey", async () => { + // Node exists but has NO synced/custom models; pricing is keyed by the + // public prefix and must be reconciled onto the unique node without + // duplicating entries, while preserving the original pricing namespace. + await nodes.createProviderNode({ + id: NODE_ID, + type: NODE_TYPE, + prefix: NODE_PREFIX, + name: "VibeProxy", + apiType: "chat", + baseUrl: "https://example.com/v1", + }); + const { updatePricing } = await import("../../src/lib/db/settings/pricing.ts"); + // Seed a user pricing row keyed by the node's public prefix. + await updatePricing({ + [NODE_PREFIX]: { "priced-model": { input_cost_per_million: 1, output_cost_per_million: 2 } }, + }); + + const catalogRes = await pricingRoute.GET(); + const catalog = (await catalogRes.json()) as Record< + string, + { id: string; displayPrefix?: string; pricingKey?: string; models: Array<{ id: string }> } + >; + const entry = Object.values(catalog).find((provider) => provider.id === NODE_ID); + assert.ok(entry, "pricing-only model must reconcile onto the compatible node"); + assert.equal(entry.displayPrefix, NODE_PREFIX); + assert.equal( + entry.pricingKey, + NODE_PREFIX, + "pricingKey must preserve the original pricing namespace" + ); + assert.deepEqual( + entry.models.map((model) => model.id), + ["priced-model"], + "pricing-only entry must be created without relying on synced/custom models" + ); + // No duplicate entry keyed by the public prefix alone. + assert.ok( + !Object.values(catalog).some( + (provider) => provider.id === NODE_PREFIX && provider.displayPrefix !== NODE_PREFIX + ), + "pricing must not create a duplicate node-keyed entry" + ); + }); + + it("no-prefix fallback and built-in providers keep raw internal id / unchanged behavior", async () => { + // Node with no prefix → target falls back to internal node id. + const noPrefixNodeId = "openai-compatible-chat-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + await nodes.createProviderNode({ + id: noPrefixNodeId, + type: NODE_TYPE, + prefix: null, + name: "NoPrefix", + apiType: "chat", + baseUrl: "https://example.com/np/v1", + }); + await models.replaceSyncedAvailableModelsForConnection(noPrefixNodeId, noPrefixNodeId, [ + { id: "np-model", name: "np-model" }, + ]); + + const patch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: `${noPrefixNodeId}/np-model`, + key: "max_output_tokens", + value: 777, + }), + }) + ); + assert.equal(patch.status, 400, "no-prefix compatible node UUID must not be writable"); + assert.equal(overrides.listModelCapabilityOverrides().length, 0); + + const rawDelete = await overrideRoute.DELETE( + new Request( + `http://localhost/api/model-capability-overrides?target=${encodeURIComponent(`${noPrefixNodeId}/np-model`)}&key=max_output_tokens`, + { method: "DELETE" } + ) + ); + assert.equal(rawDelete.status, 400); + + // Model-Overrides eligibility seam: a compatible node with NO public prefix + // is skipped from the public override list — it is never surfaced under a + // generated node UUID. Built-in / no-compatible entries remain targetable. + const get = await overrideRoute.GET( + new Request("http://localhost/api/model-capability-overrides") + ); + const payload = (await get.json()) as { overrides: Array<{ target: string }> }; + assert.ok( + !payload.overrides.some((entry) => entry.target === `${noPrefixNodeId}/np-model`), + "no-public-prefix compatible node must be skipped from the override list" + ); + + // Built-in provider (openai) unchanged. + const openaiPatch = await overrideRoute.PATCH( + new Request("http://localhost/api/model-capability-overrides", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target: "openai/gpt-4o", + key: "max_input_tokens", + value: 888, + }), + }) + ); + assert.equal(openaiPatch.status, 200); + const openaiGet = await overrideRoute.GET( + new Request("http://localhost/api/model-capability-overrides") + ); + const openaiPayload = (await openaiGet.json()) as { overrides: Array<{ target: string }> }; + assert.ok( + openaiPayload.overrides.some((entry) => entry.target === "openai/gpt-4o"), + "built-in provider target must remain openai/gpt-4o" + ); + }); +}); diff --git a/tests/unit/ui/model-capability-overrides-tab-9557.test.tsx b/tests/unit/ui/model-capability-overrides-tab-9557.test.tsx new file mode 100644 index 0000000000..a12ce9ce6c --- /dev/null +++ b/tests/unit/ui/model-capability-overrides-tab-9557.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +// +// Issue #9557 UI regression: the Model Overrides dashboard tab must render and +// PATCH/DELETE against the operator-configured public provider prefix +// (e.g. `vibeproxy/gpt-4o`), never the generated `openai-compatible-chat-` +// node id. We mount the REAL ModelCapabilityOverridesTab and drive it through +// its fetch-backed data hook with a stubbed `global.fetch`. + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ModelCapabilityOverridesTab from "@/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab"; + +const NODE_ID = "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"; +const NODE_PREFIX = "vibeproxy"; + +const roots: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function render() { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render(); + }); + roots.push({ root, el }); +} + +function jsonResponse(body: unknown, init: { ok: boolean } = { ok: true }): Response { + return new Response(JSON.stringify(body), { + status: init.ok ? 200 : 500, + headers: { "content-type": "application/json" }, + }); +} + +async function flush(): Promise { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + for (const { root, el } of roots.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.unstubAllGlobals(); +}); + +describe("ModelCapabilityOverridesTab (issue #9557)", () => { + it("renders the public prefix, never the node UUID, and PATCH/DELETE use prefix/model", async () => { + const catalog = { + [NODE_ID]: { + id: NODE_ID, + alias: NODE_ID, + displayPrefix: NODE_PREFIX, + name: "VibeProxy", + authType: "unknown", + format: "openai", + models: [{ id: "gpt-4o", name: "gpt-4o" }], + }, + }; + const overrides: Array<{ target: string; provider: string; key: string; value: number }> = [ + { + target: `${NODE_PREFIX}/gpt-4o`, + provider: NODE_PREFIX, + key: "max_output_tokens", + value: 123456, + }, + ]; + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation((input: any) => { + const url = String(input); + if (url.includes("/api/pricing/models")) { + return Promise.resolve(jsonResponse(catalog)); + } + if (url.includes("/api/model-capability-overrides")) { + return Promise.resolve(jsonResponse({ overrides })); + } + return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false })); + }); + + render(); + + // Flush the async load. + await act(async () => { + await flush(); + }); + + // The target label (public prefix) must be visible. + const bodyText = document.body.textContent ?? ""; + expect(bodyText).toContain(`${NODE_PREFIX}/gpt-4o`); + expect(bodyText).not.toContain(NODE_ID); + + // The stored override value is rendered. + expect(bodyText).toContain("123456"); + + // Click the Add button to PATCH a new override on the currently-selected + // (prefixed) target, then assert the request body used prefix/model. + const addButton = Array.from(document.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("Add key value") + ); + expect(addButton).toBeTruthy(); + // Value field + Add → PATCH with a new max_input_tokens value. + const valueInput = document.querySelector('input[type="number"]') as HTMLInputElement; + expect(valueInput).toBeTruthy(); + // Select a different key for the new override. + const select = document.querySelector("select") as HTMLSelectElement; + act(() => { + select.value = "max_input_tokens"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + act(() => { + // React controlled inputs ignore direct `.value` writes — use the native + // descriptor so the onChange handler fires with the new value. + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(valueInput, "99999"); + valueInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + addButton!.click(); + await flush(); + }); + + const patchCall = fetchMock.mock.calls.find(([input, init]) => { + const u = String(input); + const method = (init as RequestInit | undefined)?.method; + return u.includes("/api/model-capability-overrides") && method === "PATCH"; + }); + expect(patchCall).toBeTruthy(); + const patchBody = JSON.parse(String(patchCall![1].body)) as { target: string }; + expect(patchBody.target).toBe(`${NODE_PREFIX}/gpt-4o`); + expect(patchBody.target).not.toContain(NODE_ID); + + // DELETE path: the row's Remove button must send a DELETE with prefix/model. + // Re-mock GET to return the saved override so the row renders. + const withNewOverride = [ + ...overrides, + { + target: `${NODE_PREFIX}/gpt-4o`, + provider: NODE_PREFIX, + key: "max_input_tokens", + value: 99999, + }, + ]; + fetchMock.mockImplementation((input: any, init: RequestInit | undefined) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (url.includes("/api/pricing/models")) return Promise.resolve(jsonResponse(catalog)); + if (url.includes("/api/model-capability-overrides")) { + if (method === "PATCH") + return Promise.resolve(jsonResponse({ overrides: withNewOverride })); + if (method === "DELETE") + return Promise.resolve(jsonResponse({ overrides: [withNewOverride[0]] })); + return Promise.resolve(jsonResponse({ overrides: withNewOverride })); + } + return Promise.resolve(jsonResponse({ error: "unexpected" }, { ok: false })); + }); + + // Re-render fresh to pick up the new override list. + for (const { root, el } of roots.splice(0)) act(() => root.unmount()); + render(); + await act(async () => { + await flush(); + }); + + const removeButtons = Array.from(document.querySelectorAll("button")).filter( + (b) => (b.textContent ?? "").trim() === "Remove" + ); + expect(removeButtons.length).toBeGreaterThan(0); + await act(async () => { + removeButtons[0].click(); + await flush(); + }); + + const deleteCall = fetchMock.mock.calls.find(([input, init]) => { + const method = (init as RequestInit | undefined)?.method; + return method === "DELETE"; + }); + expect(deleteCall).toBeTruthy(); + const deleteUrl = String(deleteCall![0]); + expect(deleteUrl).toContain(`target=${encodeURIComponent(`${NODE_PREFIX}/gpt-4o`)}`); + expect(deleteUrl).not.toContain(NODE_ID); + }); +});