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 { aiProviders, apiKeyOrg, db } from "@server/db";
import { AiProvider, aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -13,7 +13,8 @@ export async function verifyApiKeyAiProviderAccess(
try {
const apiKey = req.apiKey;
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) {
return next(
@@ -21,23 +22,42 @@ export async function verifyApiKeyAiProviderAccess(
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
let provider: AiProvider | undefined;
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (niceId && orgIdParam) {
const [providerRes] = await db
.select()
.from(aiProviders)
.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) {
return next(
createHttpError(
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 { aiProviders, db, userOrgs } from "@server/db";
import { AiProvider, aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -15,7 +15,8 @@ export async function verifyAiProviderAccess(
try {
const userId = req.user!.userId;
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) {
return next(
@@ -23,23 +24,42 @@ export async function verifyAiProviderAccess(
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
let provider: AiProvider | undefined;
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (niceId && orgIdParam) {
const [providerRes] = await db
.select()
.from(aiProviders)
.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) {
return next(
createHttpError(
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 { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import stoi from "@server/lib/stoi";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
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 { toPublicAiProvider } from "@server/routers/aiProvider/types";
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({
method: "get",
path: "/ai-provider/{providerId}",
description: "Get an AI provider by ID.",
tags: [OpenAPITags.AiProvider],
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: {
200: {
@@ -46,22 +98,22 @@ export async function getAiProvider(
);
}
const { providerId } = parsedParams.data;
const { providerId, niceId, orgId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
const provider =
req.aiProvider &&
(req.aiProvider.providerId === providerId ||
(niceId &&
req.aiProvider.niceId === niceId &&
req.aiProvider.orgId === orgId))
? req.aiProvider
: await query(providerId, niceId, orgId);
if (!provider) {
return next(
createHttpError(
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),
aiProvider.getAiProvider
);
authenticated.get(
"/org/:orgId/ai-provider/:niceId",
verifyOrgAccess,
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.put(
"/ai-provider/:providerId/target",
+7
View File
@@ -1624,6 +1624,13 @@ authenticated.get(
verifyApiKeyHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.get(
"/org/:orgId/ai-provider/:niceId",
verifyApiKeyOrgAccess,
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.put(
"/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()
.trim()
.min(1, { message: t("nameRequired") }),
niceId: z.string().min(1).max(255).optional(),
enabled: z.boolean(),
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional()
})
@@ -76,6 +77,7 @@ export default function AiProviderGeneralPage() {
resolver: zodResolver(generalSchema),
defaultValues: {
name: provider.name,
niceId: provider.niceId,
enabled: provider.enabled,
capabilities: provider.capabilities ?? []
}
@@ -86,10 +88,12 @@ export default function AiProviderGeneralPage() {
try {
const body: {
name: string;
niceId?: string;
enabled: boolean;
capabilities?: AiCapability[];
} = {
name: values.name.trim(),
niceId: values.niceId,
enabled: values.enabled,
capabilities: values.capabilities ?? []
};
@@ -101,6 +105,7 @@ export default function AiProviderGeneralPage() {
updateProvider(updated);
form.reset({
name: updated.name,
niceId: updated.niceId,
enabled: updated.enabled,
capabilities: updated.capabilities ?? []
});
@@ -108,6 +113,13 @@ export default function AiProviderGeneralPage() {
title: t("success"),
description: t("aiProviderUpdated")
});
if (values.niceId && values.niceId !== provider.niceId) {
router.replace(
`/${provider.orgId}/settings/ai-providers/${values.niceId}/general`
);
}
router.refresh();
} catch (e) {
toast({
@@ -190,6 +202,29 @@ export default function AiProviderGeneralPage() {
/>
</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">
<FormField
control={form.control}
@@ -20,17 +20,17 @@ export const dynamic = "force-dynamic";
type Props = {
children: React.ReactNode;
params: Promise<{ orgId: string; providerId: string }>;
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function AiProviderLayout({ children, params }: Props) {
const { orgId, providerId } = await params;
const { orgId, niceId } = await params;
const t = await getTranslations();
let provider = null;
try {
const res = await internal.get<AxiosResponse<GetAiProviderResponse>>(
`/ai-provider/${providerId}`,
`/org/${orgId}/ai-provider/${niceId}`,
await authCookieHeader()
);
provider = res.data.data.provider;
@@ -63,23 +63,23 @@ export default async function AiProviderLayout({ children, params }: Props) {
const navItems = [
{
title: t("general"),
href: "/{orgId}/settings/ai-providers/{providerId}/general"
href: "/{orgId}/settings/ai-providers/{niceId}/general"
},
{
title: t("aiProviderNetworkSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/network"
href: "/{orgId}/settings/ai-providers/{niceId}/network"
},
{
title: t("aiProviderModels"),
href: "/{orgId}/settings/ai-providers/{providerId}/models"
href: "/{orgId}/settings/ai-providers/{niceId}/models"
},
{
title: t("aiProviderAuthSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
href: "/{orgId}/settings/ai-providers/{niceId}/authentication"
},
{
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));
const providerId = res.data.data.provider.providerId;
const niceId = res.data.data.provider.niceId;
if (showTargets && targets.length > 0) {
try {
@@ -236,7 +237,7 @@ export default function CreateAiProviderPage() {
)
});
router.push(
`/${orgId}/settings/ai-providers/${providerId}/network`
`/${orgId}/settings/ai-providers/${niceId}/network`
);
return;
}
@@ -255,7 +256,7 @@ export default function CreateAiProviderPage() {
)
});
router.push(
`/${orgId}/settings/ai-providers/${providerId}/models`
`/${orgId}/settings/ai-providers/${niceId}/models`
);
return;
}
@@ -266,7 +267,7 @@ export default function CreateAiProviderPage() {
description: t("aiProviderCreated")
});
router.push(`/${orgId}/settings/ai-providers/${providerId}`);
router.push(`/${orgId}/settings/ai-providers/${niceId}`);
} catch (e) {
toast({
variant: "destructive",
@@ -53,6 +53,7 @@ export default async function AiProvidersPage({ params, searchParams }: Props) {
orgId={orgId}
providers={providers.map((provider) => ({
providerId: provider.providerId,
niceId: provider.niceId,
name: provider.name,
type: provider.type,
routingMode: provider.routingMode,
+4 -3
View File
@@ -27,6 +27,7 @@ import { useDebouncedCallback } from "use-debounce";
export type AiProviderRow = {
providerId: number;
niceId: string;
name: string;
type: string;
routingMode: string;
@@ -176,7 +177,7 @@ export default function AiProvidersTable({
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
className="hover:underline"
>
{row.original.name}
@@ -238,7 +239,7 @@ export default function AiProvidersTable({
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
>
{t("edit")}
</Link>
@@ -256,7 +257,7 @@ export default function AiProvidersTable({
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
>
<Button variant="outline">
{t("edit")}