Add niceid to ui and api for providers

This commit is contained in:
Owen
2026-08-14 09:43:03 -04:00
parent f0f5e9219b
commit 5d37f44241
18 changed files with 219 additions and 75 deletions
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { aiProviders, apiKeyOrg, db } from "@server/db"; import { AiProvider, aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -13,7 +13,8 @@ export async function verifyApiKeyAiProviderAccess(
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const providerIdRaw = getFirstString(req.params.providerId); const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10); const niceId = getFirstString(req.params.niceId);
const orgIdParam = getFirstString(req.params.orgId);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -21,23 +22,42 @@ export async function verifyApiKeyAiProviderAccess(
); );
} }
if (Number.isNaN(providerId)) { let provider: AiProvider | undefined;
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db if (niceId && orgIdParam) {
.select() const [providerRes] = await db
.from(aiProviders) .select()
.where(eq(aiProviders.providerId, providerId)) .from(aiProviders)
.limit(1); .where(
and(
eq(aiProviders.niceId, niceId),
eq(aiProviders.orgId, orgIdParam)
)
)
.limit(1);
provider = providerRes;
} else {
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [providerRes] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
provider = providerRes;
}
if (!provider) { if (!provider) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found` `AI provider with ID ${providerIdRaw || niceId} not found`
) )
); );
} }
+33 -13
View File
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { aiProviders, db, userOrgs } from "@server/db"; import { AiProvider, aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -15,7 +15,8 @@ export async function verifyAiProviderAccess(
try { try {
const userId = req.user!.userId; const userId = req.user!.userId;
const providerIdRaw = getFirstString(req.params.providerId); const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10); const niceId = getFirstString(req.params.niceId);
const orgIdParam = getFirstString(req.params.orgId);
if (!userId) { if (!userId) {
return next( return next(
@@ -23,23 +24,42 @@ export async function verifyAiProviderAccess(
); );
} }
if (Number.isNaN(providerId)) { let provider: AiProvider | undefined;
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db if (niceId && orgIdParam) {
.select() const [providerRes] = await db
.from(aiProviders) .select()
.where(eq(aiProviders.providerId, providerId)) .from(aiProviders)
.limit(1); .where(
and(
eq(aiProviders.niceId, niceId),
eq(aiProviders.orgId, orgIdParam)
)
)
.limit(1);
provider = providerRes;
} else {
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [providerRes] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
provider = providerRes;
}
if (!provider) { if (!provider) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found` `AI provider with ID ${providerIdRaw || niceId} not found`
) )
); );
} }
+65 -13
View File
@@ -2,26 +2,78 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { aiProviders, db } from "@server/db"; import { aiProviders, db } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import stoi from "@server/lib/stoi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; import type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive() providerId: z
.string()
.optional()
.transform(stoi)
.pipe(z.int().positive().optional())
.optional(),
niceId: z.string().optional(),
orgId: z.string().optional()
}); });
async function query(providerId?: number, niceId?: string, orgId?: string) {
if (providerId) {
const [res] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
return res;
} else if (niceId && orgId) {
const [res] = await db
.select()
.from(aiProviders)
.where(
and(
eq(aiProviders.niceId, niceId),
eq(aiProviders.orgId, orgId)
)
)
.limit(1);
return res;
}
}
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/ai-provider/{providerId}", path: "/ai-provider/{providerId}",
description: "Get an AI provider by ID.", description: "Get an AI provider by ID.",
tags: [OpenAPITags.AiProvider], tags: [OpenAPITags.AiProvider],
request: { request: {
params: paramsSchema params: z.object({
providerId: z.string()
})
},
responses: {
200: {
description: "Successful response"
}
}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-provider/{niceId}",
description:
"Get an AI provider by orgId and niceId. NiceId is a readable ID for the provider and unique on a per org basis.",
tags: [OpenAPITags.AiProvider],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
}, },
responses: { responses: {
200: { 200: {
@@ -46,22 +98,22 @@ export async function getAiProvider(
); );
} }
const { providerId } = parsedParams.data; const { providerId, niceId, orgId } = parsedParams.data;
const [provider] = const provider =
req.aiProvider && req.aiProvider.providerId === providerId req.aiProvider &&
? [req.aiProvider] (req.aiProvider.providerId === providerId ||
: await db (niceId &&
.select() req.aiProvider.niceId === niceId &&
.from(aiProviders) req.aiProvider.orgId === orgId))
.where(eq(aiProviders.providerId, providerId)) ? req.aiProvider
.limit(1); : await query(providerId, niceId, orgId);
if (!provider) { if (!provider) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found` `AI provider with ID ${providerId || niceId} not found`
) )
); );
} }
+7
View File
@@ -1589,6 +1589,13 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getAiProvider), verifyUserHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider aiProvider.getAiProvider
); );
authenticated.get(
"/org/:orgId/ai-provider/:niceId",
verifyOrgAccess,
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.put( authenticated.put(
"/ai-provider/:providerId/target", "/ai-provider/:providerId/target",
+7
View File
@@ -1624,6 +1624,13 @@ authenticated.get(
verifyApiKeyHasAction(ActionsEnum.getAiProvider), verifyApiKeyHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider aiProvider.getAiProvider
); );
authenticated.get(
"/org/:orgId/ai-provider/:niceId",
verifyApiKeyOrgAccess,
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.put( authenticated.put(
"/ai-provider/:providerId/target", "/ai-provider/:providerId/target",
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
type Props = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function AiProviderConfigurationRedirect({
params
}: Props) {
const { orgId, niceId } = await params;
redirect(`/${orgId}/settings/ai-providers/${niceId}/network`);
}
@@ -55,6 +55,7 @@ export default function AiProviderGeneralPage() {
.string() .string()
.trim() .trim()
.min(1, { message: t("nameRequired") }), .min(1, { message: t("nameRequired") }),
niceId: z.string().min(1).max(255).optional(),
enabled: z.boolean(), enabled: z.boolean(),
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional() capabilities: z.array(z.enum(AI_CAPABILITIES)).optional()
}) })
@@ -76,6 +77,7 @@ export default function AiProviderGeneralPage() {
resolver: zodResolver(generalSchema), resolver: zodResolver(generalSchema),
defaultValues: { defaultValues: {
name: provider.name, name: provider.name,
niceId: provider.niceId,
enabled: provider.enabled, enabled: provider.enabled,
capabilities: provider.capabilities ?? [] capabilities: provider.capabilities ?? []
} }
@@ -86,10 +88,12 @@ export default function AiProviderGeneralPage() {
try { try {
const body: { const body: {
name: string; name: string;
niceId?: string;
enabled: boolean; enabled: boolean;
capabilities?: AiCapability[]; capabilities?: AiCapability[];
} = { } = {
name: values.name.trim(), name: values.name.trim(),
niceId: values.niceId,
enabled: values.enabled, enabled: values.enabled,
capabilities: values.capabilities ?? [] capabilities: values.capabilities ?? []
}; };
@@ -101,6 +105,7 @@ export default function AiProviderGeneralPage() {
updateProvider(updated); updateProvider(updated);
form.reset({ form.reset({
name: updated.name, name: updated.name,
niceId: updated.niceId,
enabled: updated.enabled, enabled: updated.enabled,
capabilities: updated.capabilities ?? [] capabilities: updated.capabilities ?? []
}); });
@@ -108,6 +113,13 @@ export default function AiProviderGeneralPage() {
title: t("success"), title: t("success"),
description: t("aiProviderUpdated") description: t("aiProviderUpdated")
}); });
if (values.niceId && values.niceId !== provider.niceId) {
router.replace(
`/${provider.orgId}/settings/ai-providers/${values.niceId}/general`
);
}
router.refresh(); router.refresh();
} catch (e) { } catch (e) {
toast({ toast({
@@ -190,6 +202,29 @@ export default function AiProviderGeneralPage() {
/> />
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<FormField <FormField
control={form.control} control={form.control}
@@ -20,17 +20,17 @@ export const dynamic = "force-dynamic";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ orgId: string; providerId: string }>; params: Promise<{ orgId: string; niceId: string }>;
}; };
export default async function AiProviderLayout({ children, params }: Props) { export default async function AiProviderLayout({ children, params }: Props) {
const { orgId, providerId } = await params; const { orgId, niceId } = await params;
const t = await getTranslations(); const t = await getTranslations();
let provider = null; let provider = null;
try { try {
const res = await internal.get<AxiosResponse<GetAiProviderResponse>>( const res = await internal.get<AxiosResponse<GetAiProviderResponse>>(
`/ai-provider/${providerId}`, `/org/${orgId}/ai-provider/${niceId}`,
await authCookieHeader() await authCookieHeader()
); );
provider = res.data.data.provider; provider = res.data.data.provider;
@@ -63,23 +63,23 @@ export default async function AiProviderLayout({ children, params }: Props) {
const navItems = [ const navItems = [
{ {
title: t("general"), title: t("general"),
href: "/{orgId}/settings/ai-providers/{providerId}/general" href: "/{orgId}/settings/ai-providers/{niceId}/general"
}, },
{ {
title: t("aiProviderNetworkSettings"), title: t("aiProviderNetworkSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/network" href: "/{orgId}/settings/ai-providers/{niceId}/network"
}, },
{ {
title: t("aiProviderModels"), title: t("aiProviderModels"),
href: "/{orgId}/settings/ai-providers/{providerId}/models" href: "/{orgId}/settings/ai-providers/{niceId}/models"
}, },
{ {
title: t("aiProviderAuthSettings"), title: t("aiProviderAuthSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/authentication" href: "/{orgId}/settings/ai-providers/{niceId}/authentication"
}, },
{ {
title: t("aiProviderBudgetSettings"), title: t("aiProviderBudgetSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/budget" href: "/{orgId}/settings/ai-providers/{niceId}/budget"
} }
]; ];
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
type Props = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function AiProviderPage({ params }: Props) {
const { orgId, niceId } = await params;
redirect(`/${orgId}/settings/ai-providers/${niceId}/general`);
}
@@ -1,12 +0,0 @@
import { redirect } from "next/navigation";
type Props = {
params: Promise<{ orgId: string; providerId: string }>;
};
export default async function AiProviderConfigurationRedirect({
params
}: Props) {
const { orgId, providerId } = await params;
redirect(`/${orgId}/settings/ai-providers/${providerId}/network`);
}
@@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
type Props = {
params: Promise<{ orgId: string; providerId: string }>;
};
export default async function AiProviderPage({ params }: Props) {
const { orgId, providerId } = await params;
redirect(`/${orgId}/settings/ai-providers/${providerId}/general`);
}
@@ -222,6 +222,7 @@ export default function CreateAiProviderPage() {
>(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values)); >(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values));
const providerId = res.data.data.provider.providerId; const providerId = res.data.data.provider.providerId;
const niceId = res.data.data.provider.niceId;
if (showTargets && targets.length > 0) { if (showTargets && targets.length > 0) {
try { try {
@@ -236,7 +237,7 @@ export default function CreateAiProviderPage() {
) )
}); });
router.push( router.push(
`/${orgId}/settings/ai-providers/${providerId}/network` `/${orgId}/settings/ai-providers/${niceId}/network`
); );
return; return;
} }
@@ -255,7 +256,7 @@ export default function CreateAiProviderPage() {
) )
}); });
router.push( router.push(
`/${orgId}/settings/ai-providers/${providerId}/models` `/${orgId}/settings/ai-providers/${niceId}/models`
); );
return; return;
} }
@@ -266,7 +267,7 @@ export default function CreateAiProviderPage() {
description: t("aiProviderCreated") description: t("aiProviderCreated")
}); });
router.push(`/${orgId}/settings/ai-providers/${providerId}`); router.push(`/${orgId}/settings/ai-providers/${niceId}`);
} catch (e) { } catch (e) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -53,6 +53,7 @@ export default async function AiProvidersPage({ params, searchParams }: Props) {
orgId={orgId} orgId={orgId}
providers={providers.map((provider) => ({ providers={providers.map((provider) => ({
providerId: provider.providerId, providerId: provider.providerId,
niceId: provider.niceId,
name: provider.name, name: provider.name,
type: provider.type, type: provider.type,
routingMode: provider.routingMode, routingMode: provider.routingMode,
+4 -3
View File
@@ -27,6 +27,7 @@ import { useDebouncedCallback } from "use-debounce";
export type AiProviderRow = { export type AiProviderRow = {
providerId: number; providerId: number;
niceId: string;
name: string; name: string;
type: string; type: string;
routingMode: string; routingMode: string;
@@ -176,7 +177,7 @@ export default function AiProvidersTable({
header: () => <span className="p-3">{t("name")}</span>, header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Link <Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`} href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
className="hover:underline" className="hover:underline"
> >
{row.original.name} {row.original.name}
@@ -238,7 +239,7 @@ export default function AiProvidersTable({
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`} href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
> >
{t("edit")} {t("edit")}
</Link> </Link>
@@ -256,7 +257,7 @@ export default function AiProvidersTable({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Link <Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`} href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
> >
<Button variant="outline"> <Button variant="outline">
{t("edit")} {t("edit")}