mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-06 04:31:22 +02:00
add basic ui for private inference resource
This commit is contained in:
@@ -1714,6 +1714,20 @@
|
|||||||
"aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?",
|
"aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?",
|
||||||
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
||||||
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
||||||
|
"aiProviderModels": "Models",
|
||||||
|
"aiProviderModelsDescription": "Define model names available on this provider. Requests must use one of these model keys.",
|
||||||
|
"aiProviderModelsPlaceholder": "Type a model name and press Enter",
|
||||||
|
"aiProviderModelsUpdated": "Models updated",
|
||||||
|
"aiProviderModelsErrorUpdate": "Failed to update models",
|
||||||
|
"aiResourceProviders": "Providers",
|
||||||
|
"aiResourceProvidersDescription": "Choose which AI providers this inference resource can use",
|
||||||
|
"aiResourceProvidersHelp": "Models must be defined on each provider. Model names cannot overlap across selected providers.",
|
||||||
|
"aiResourceProvidersSelect": "Select providers",
|
||||||
|
"aiResourceProvidersEmpty": "No AI providers found",
|
||||||
|
"aiResourceProvidersRequired": "Select at least one AI provider",
|
||||||
|
"aiResourceProvidersUpdated": "Providers updated",
|
||||||
|
"aiResourceProvidersErrorUpdate": "Failed to update providers",
|
||||||
|
"aiResourceAliasRequired": "Alias is required for inference resources",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ export const resourceAiProviders = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: varchar("modelAccessMode")
|
modelAccessMode: varchar("modelAccessMode")
|
||||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
.$type<"catalog" | "allowlist">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("passthrough")
|
.default("catalog")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
@@ -523,9 +523,9 @@ export const siteResourceAiProviders = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: varchar("modelAccessMode")
|
modelAccessMode: varchar("modelAccessMode")
|
||||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
.$type<"catalog" | "allowlist">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("passthrough")
|
.default("catalog")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -229,9 +229,9 @@ export const resourceAiProviders = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: text("modelAccessMode")
|
modelAccessMode: text("modelAccessMode")
|
||||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
.$type<"catalog" | "allowlist">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("passthrough")
|
.default("catalog")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
@@ -508,9 +508,9 @@ export const siteResourceAiProviders = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||||
modelAccessMode: text("modelAccessMode")
|
modelAccessMode: text("modelAccessMode")
|
||||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
.$type<"catalog" | "allowlist">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("passthrough")
|
.default("catalog")
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
type DbOrTrx = Transaction | typeof db;
|
type DbOrTrx = Transaction | typeof db;
|
||||||
|
|
||||||
export const modelAccessModeSchema = z.enum([
|
export const modelAccessModeSchema = z.enum(["catalog", "allowlist"]);
|
||||||
"passthrough",
|
|
||||||
"catalog",
|
|
||||||
"allowlist"
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type ModelAccessMode = z.infer<typeof modelAccessModeSchema>;
|
export type ModelAccessMode = z.infer<typeof modelAccessModeSchema>;
|
||||||
|
|
||||||
@@ -50,10 +46,7 @@ function normalizeAttachments(
|
|||||||
): ResourceAiProviderAttachment[] {
|
): ResourceAiProviderAttachment[] {
|
||||||
const byProvider = new Map<number, ModelAccessMode>();
|
const byProvider = new Map<number, ModelAccessMode>();
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
byProvider.set(
|
byProvider.set(input.providerId, input.modelAccessMode ?? "catalog");
|
||||||
input.providerId,
|
|
||||||
input.modelAccessMode ?? "passthrough"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({
|
return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({
|
||||||
providerId,
|
providerId,
|
||||||
@@ -61,9 +54,61 @@ function normalizeAttachments(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure enabled catalog modelKeys are unique across attached providers.
|
||||||
|
* Catalog attachments contribute all enabled models on the provider.
|
||||||
|
* Allowlist attachments contribute nothing until models are allowlisted
|
||||||
|
* (those are checked when the allowlist is set).
|
||||||
|
*/
|
||||||
|
export async function assertNoOverlappingModelKeys(
|
||||||
|
attachments: ResourceAiProviderAttachment[],
|
||||||
|
trx: DbOrTrx = db
|
||||||
|
): Promise<InferenceFieldsError | null> {
|
||||||
|
const catalogProviderIds = attachments
|
||||||
|
.filter((a) => a.modelAccessMode === "catalog")
|
||||||
|
.map((a) => a.providerId);
|
||||||
|
|
||||||
|
if (catalogProviderIds.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = await trx
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(aiModels.providerId, catalogProviderIds),
|
||||||
|
eq(aiModels.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyToProviders = new Map<string, number[]>();
|
||||||
|
for (const model of models) {
|
||||||
|
const existing = keyToProviders.get(model.modelKey) ?? [];
|
||||||
|
if (!existing.includes(model.providerId)) {
|
||||||
|
existing.push(model.providerId);
|
||||||
|
}
|
||||||
|
keyToProviders.set(model.modelKey, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlaps = [...keyToProviders.entries()].filter(
|
||||||
|
([, providerIds]) => providerIds.length > 1
|
||||||
|
);
|
||||||
|
if (overlaps.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = overlaps.map(([key]) => key).sort();
|
||||||
|
return {
|
||||||
|
error: `Model keys must be unique across providers on a resource. Overlapping keys: ${keys.join(", ")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate provider attachments for an org.
|
* Validate provider attachments for an org.
|
||||||
* At most one passthrough provider is allowed per resource.
|
|
||||||
*/
|
*/
|
||||||
export async function resolveProviderAttachments(input: {
|
export async function resolveProviderAttachments(input: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -78,15 +123,6 @@ export async function resolveProviderAttachments(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const passthroughCount = attachments.filter(
|
|
||||||
(a) => a.modelAccessMode === "passthrough"
|
|
||||||
).length;
|
|
||||||
if (passthroughCount > 1) {
|
|
||||||
return {
|
|
||||||
error: "A resource may have at most one AI provider in passthrough mode"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachments.length === 0) {
|
if (attachments.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -119,6 +155,11 @@ export async function resolveProviderAttachments(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overlapError = await assertNoOverlappingModelKeys(attachments);
|
||||||
|
if (overlapError) {
|
||||||
|
return overlapError;
|
||||||
|
}
|
||||||
|
|
||||||
return attachments;
|
return attachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -331,10 +331,6 @@ async function providerMatchesModel(
|
|||||||
requestedModel: string,
|
requestedModel: string,
|
||||||
allowedModelIds: number[]
|
allowedModelIds: number[]
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (attachment.modelAccessMode === "passthrough") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [matchedModel] = await db
|
const [matchedModel] = await db
|
||||||
.select({
|
.select({
|
||||||
modelId: aiModels.modelId,
|
modelId: aiModels.modelId,
|
||||||
@@ -366,26 +362,7 @@ async function selectProvider(
|
|||||||
allowedModelIds: number[],
|
allowedModelIds: number[],
|
||||||
requestedModel: string | undefined
|
requestedModel: string | undefined
|
||||||
): Promise<ProviderSelection> {
|
): Promise<ProviderSelection> {
|
||||||
const passthroughAttachments = attachments.filter(
|
|
||||||
(a) => a.modelAccessMode === "passthrough"
|
|
||||||
);
|
|
||||||
const hasRestricted = attachments.some(
|
|
||||||
(a) =>
|
|
||||||
a.modelAccessMode === "catalog" || a.modelAccessMode === "allowlist"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
if (hasRestricted) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: HttpCode.FORBIDDEN,
|
|
||||||
message:
|
|
||||||
"This resource restricts access to specific models; a model must be specified"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (passthroughAttachments.length === 1) {
|
|
||||||
return { ok: true, provider: passthroughAttachments[0].provider };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: HttpCode.FORBIDDEN,
|
status: HttpCode.FORBIDDEN,
|
||||||
@@ -395,10 +372,6 @@ async function selectProvider(
|
|||||||
|
|
||||||
const candidates: ProviderAttachment[] = [];
|
const candidates: ProviderAttachment[] = [];
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
if (attachment.modelAccessMode === "passthrough") {
|
|
||||||
candidates.push(attachment);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
await providerMatchesModel(
|
await providerMatchesModel(
|
||||||
attachment,
|
attachment,
|
||||||
@@ -422,11 +395,6 @@ async function selectProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero candidates: fall back to a single passthrough attachment if present
|
|
||||||
if (passthroughAttachments.length === 1) {
|
|
||||||
return { ok: true, provider: passthroughAttachments[0].provider };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: HttpCode.FORBIDDEN,
|
status: HttpCode.FORBIDDEN,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const createHttpResourceSchema = z
|
|||||||
.array(resourceAiProviderAttachmentSchema)
|
.array(resourceAiProviderAttachmentSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"For inference-mode resources: AI providers to attach. Each entry may set modelAccessMode (passthrough, catalog, or allowlist); defaults to passthrough. At most one passthrough provider is allowed."
|
"For inference-mode resources: AI providers to attach. Each entry may set modelAccessMode (catalog or allowlist); defaults to catalog. Model keys must be unique across attached catalog providers."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
@@ -397,9 +397,7 @@ async function createHttpResource(
|
|||||||
requireAtLeastOne: true
|
requireAtLeastOne: true
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(resolved)) {
|
if (isInferenceFieldsError(resolved)) {
|
||||||
return next(
|
return next(createHttpError(HttpCode.BAD_REQUEST, resolved.error));
|
||||||
createHttpError(HttpCode.BAD_REQUEST, resolved.error)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
providerAttachments = resolved;
|
providerAttachments = resolved;
|
||||||
} else if (aiProviderInputs && aiProviderInputs.length > 0) {
|
} else if (aiProviderInputs && aiProviderInputs.length > 0) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/resource/{resourceId}/ai-providers",
|
path: "/resource/{resourceId}/ai-providers",
|
||||||
description:
|
description:
|
||||||
"Replace the AI providers attached to an inference resource. At least one provider is required. At most one may use passthrough mode.",
|
"Replace the AI providers attached to an inference resource. At least one provider is required. Model keys must be unique across attached catalog providers.",
|
||||||
tags: [OpenAPITags.PublicResource],
|
tags: [OpenAPITags.PublicResource],
|
||||||
request: {
|
request: {
|
||||||
params: setResourceAiProvidersParamsSchema,
|
params: setResourceAiProvidersParamsSchema,
|
||||||
@@ -116,7 +116,9 @@ export async function setResourceAiProviders(
|
|||||||
requireAtLeastOne: true
|
requireAtLeastOne: true
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await setPublicResourceAiProviders(resourceId, attachments);
|
await setPublicResourceAiProviders(resourceId, attachments);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const createSiteResourceSchema = z
|
|||||||
.array(resourceAiProviderAttachmentSchema)
|
.array(resourceAiProviderAttachmentSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"For inference-mode site resources: AI providers to attach. Each entry may set modelAccessMode (passthrough, catalog, or allowlist); defaults to passthrough. At most one passthrough provider is allowed."
|
"For inference-mode site resources: AI providers to attach. Each entry may set modelAccessMode (catalog or allowlist); defaults to catalog. Model keys must be unique across attached catalog providers."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/site-resource/{siteResourceId}/ai-providers",
|
path: "/site-resource/{siteResourceId}/ai-providers",
|
||||||
description:
|
description:
|
||||||
"Replace the AI providers attached to an inference site resource. At least one provider is required. At most one may use passthrough mode.",
|
"Replace the AI providers attached to an inference site resource. At least one provider is required. Model keys must be unique across attached catalog providers.",
|
||||||
tags: [OpenAPITags.PrivateResource],
|
tags: [OpenAPITags.PrivateResource],
|
||||||
request: {
|
request: {
|
||||||
params: setSiteResourceAiProvidersParamsSchema,
|
params: setSiteResourceAiProvidersParamsSchema,
|
||||||
@@ -118,7 +118,9 @@ export async function setSiteResourceAiProviders(
|
|||||||
requireAtLeastOne: true
|
requireAtLeastOne: true
|
||||||
});
|
});
|
||||||
if (isInferenceFieldsError(attachments)) {
|
if (isInferenceFieldsError(attachments)) {
|
||||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await replaceAttachments(siteResourceId, attachments);
|
await replaceAttachments(siteResourceId, attachments);
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export default async function AiProviderLayout({ children, params }: Props) {
|
|||||||
title: t("aiProviderNetworkSettings"),
|
title: t("aiProviderNetworkSettings"),
|
||||||
href: "/{orgId}/settings/ai-providers/{providerId}/network"
|
href: "/{orgId}/settings/ai-providers/{providerId}/network"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("aiProviderModels"),
|
||||||
|
href: "/{orgId}/settings/ai-providers/{providerId}/models"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("aiProviderAuthSettings"),
|
title: t("aiProviderAuthSettings"),
|
||||||
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
|
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { aiProviderQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function AiProviderModelsPage() {
|
||||||
|
const { provider } = useAiProviderContext();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const t = useTranslations();
|
||||||
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const modelsQuery = useQuery(
|
||||||
|
aiProviderQueries.providerModels({ providerId: provider.providerId })
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modelsQuery.data) return;
|
||||||
|
setTags(
|
||||||
|
modelsQuery.data.map((model) => ({
|
||||||
|
id: String(model.modelId),
|
||||||
|
text: model.modelKey
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [modelsQuery.data]);
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
setSaveLoading(true);
|
||||||
|
try {
|
||||||
|
const existing = modelsQuery.data ?? [];
|
||||||
|
const existingByKey = new Map(
|
||||||
|
existing.map((model) => [model.modelKey, model])
|
||||||
|
);
|
||||||
|
const nextKeys = new Set(
|
||||||
|
tags.map((tag) => tag.text.trim()).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreate = [...nextKeys].filter(
|
||||||
|
(key) => !existingByKey.has(key)
|
||||||
|
);
|
||||||
|
const toDelete = existing.filter(
|
||||||
|
(model) => !nextKeys.has(model.modelKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...toCreate.map((modelKey) =>
|
||||||
|
api.put(`/ai-provider/${provider.providerId}/model`, {
|
||||||
|
modelKey,
|
||||||
|
name: modelKey
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...toDelete.map((model) =>
|
||||||
|
api.delete(`/ai-model/${model.modelId}`)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
aiProviderQueries.providerModels({
|
||||||
|
providerId: provider.providerId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("aiProviderModelsUpdated")
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("aiProviderModelsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("aiProviderModelsErrorUpdate")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("aiProviderModels")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("aiProviderModelsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<TagInput
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
placeholder={t("aiProviderModelsPlaceholder")}
|
||||||
|
size="sm"
|
||||||
|
tags={tags}
|
||||||
|
setTags={(newTags) => {
|
||||||
|
const next =
|
||||||
|
typeof newTags === "function"
|
||||||
|
? newTags(tags)
|
||||||
|
: newTags;
|
||||||
|
setTags(next as Tag[]);
|
||||||
|
}}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags
|
||||||
|
delimiterList={[",", "Enter"]}
|
||||||
|
disabled={modelsQuery.isLoading || saveLoading}
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={saveLoading}
|
||||||
|
disabled={saveLoading || modelsQuery.isLoading}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ import {
|
|||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
|
||||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||||
|
import { asAnyControl, asAnyWatch } from "@app/lib/formControlUtils";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useActionState, useMemo } from "react";
|
import { useActionState, useMemo } from "react";
|
||||||
@@ -35,8 +37,12 @@ import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
|||||||
export default function PrivateResourceGeneralPage() {
|
export default function PrivateResourceGeneralPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { save, siteResource } = useSaveSiteResource();
|
const { save, siteResource } = useSaveSiteResource();
|
||||||
|
const isInference = siteResource.mode === "inference";
|
||||||
|
|
||||||
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
|
const formSchema = useMemo(
|
||||||
|
() => createGeneralFormSchema(t, { requireAlias: isInference }),
|
||||||
|
[t, isInference]
|
||||||
|
);
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@@ -44,7 +50,8 @@ export default function PrivateResourceGeneralPage() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: siteResource.name,
|
name: siteResource.name,
|
||||||
niceId: siteResource.niceId,
|
niceId: siteResource.niceId,
|
||||||
enabled: siteResource.enabled
|
enabled: siteResource.enabled,
|
||||||
|
alias: siteResource.alias ?? null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +63,13 @@ export default function PrivateResourceGeneralPage() {
|
|||||||
await save({
|
await save({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
enabled: data.enabled
|
enabled: data.enabled,
|
||||||
|
...(isInference
|
||||||
|
? {
|
||||||
|
mode: "inference" as const,
|
||||||
|
alias: data.alias
|
||||||
|
}
|
||||||
|
: {})
|
||||||
});
|
});
|
||||||
}, null);
|
}, null);
|
||||||
|
|
||||||
@@ -152,6 +165,17 @@ export default function PrivateResourceGeneralPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
{isInference && (
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceAliasField
|
||||||
|
control={asAnyControl(
|
||||||
|
form.control
|
||||||
|
)}
|
||||||
|
watch={asAnyWatch(form.watch)}
|
||||||
|
labelPrefix="edit"
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
</SettingsFormGrid>
|
</SettingsFormGrid>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -1,105 +1,15 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import {
|
export const metadata: Metadata = {
|
||||||
SettingsContainer,
|
title: "Private Resource"
|
||||||
SettingsFormCell,
|
};
|
||||||
SettingsFormGrid,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionFooter,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { Form } from "@app/components/ui/form";
|
|
||||||
import { createInferenceFormSchema } from "@app/lib/privateResourceForm";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useActionState, useMemo, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields";
|
|
||||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
|
||||||
import {
|
|
||||||
asAnyControl,
|
|
||||||
asAnySetValue,
|
|
||||||
asAnyWatch
|
|
||||||
} from "@app/lib/formControlUtils";
|
|
||||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
|
||||||
|
|
||||||
export default function PrivateResourceInferencePage() {
|
export default async function PrivateResourceInferencePage(props: {
|
||||||
const t = useTranslations();
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
const { save, siteResource } = useSaveSiteResource();
|
}) {
|
||||||
const [selectedSites, setSelectedSites] = useState(() =>
|
const params = await props.params;
|
||||||
buildSelectedSitesForResource(siteResource)
|
redirect(
|
||||||
);
|
`/${params.orgId}/settings/resources/private/${params.niceId}/providers`
|
||||||
|
|
||||||
const formSchema = useMemo(() => createInferenceFormSchema(t), [t]);
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
mode: "inference",
|
|
||||||
alias: siteResource.alias ?? null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, formAction, saveLoading] = useActionState(async () => {
|
|
||||||
const isValid = await form.trigger();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
const data = form.getValues();
|
|
||||||
await save({
|
|
||||||
mode: "inference",
|
|
||||||
alias: data.alias
|
|
||||||
});
|
|
||||||
}, null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContainer>
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("hostSettings")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("editInternalResourceDialogDestinationDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm variant="half">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
id="private-resource-host-form"
|
|
||||||
>
|
|
||||||
<SettingsFormGrid>
|
|
||||||
<SettingsFormCell span="full">
|
|
||||||
<PrivateResourceInferenceDestinationFields
|
|
||||||
control={asAnyControl(form.control)}
|
|
||||||
watch={asAnyWatch(form.watch)}
|
|
||||||
/>
|
|
||||||
</SettingsFormCell>
|
|
||||||
</SettingsFormGrid>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
|
|
||||||
<SettingsSectionFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="private-resource-host-form"
|
|
||||||
loading={saveLoading}
|
|
||||||
>
|
|
||||||
{t("saveSettings")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionFooter>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,24 @@ export default async function PrivateResourceLayout(
|
|||||||
| "sshSettings"
|
| "sshSettings"
|
||||||
| "inferenceSettings";
|
| "inferenceSettings";
|
||||||
|
|
||||||
const navItems = [
|
const isInference = siteResource.mode === "inference";
|
||||||
|
|
||||||
|
const navItems = isInference
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("general"),
|
||||||
|
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("aiResourceProviders"),
|
||||||
|
href: `/{orgId}/settings/resources/private/{niceId}/providers`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("authentication"),
|
||||||
|
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import {
|
||||||
|
AiProvidersSelector,
|
||||||
|
type SelectedAiProvider
|
||||||
|
} from "@app/components/AiProvidersSelector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { resourceQueries } from "@app/lib/queries";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export default function PrivateResourceProvidersPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { siteResource } = useSiteResourceContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (siteResource.mode !== "inference") {
|
||||||
|
router.replace(
|
||||||
|
`/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [router, siteResource.mode, siteResource.niceId, siteResource.orgId]);
|
||||||
|
|
||||||
|
const formSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
providerIds: z
|
||||||
|
.array(z.number().int().positive())
|
||||||
|
.min(1, t("aiResourceProvidersRequired"))
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const [selectedProviders, setSelectedProviders] = useState<
|
||||||
|
SelectedAiProvider[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const attachedQuery = useQuery({
|
||||||
|
...resourceQueries.siteResourceAiProviders({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
}),
|
||||||
|
enabled: siteResource.mode === "inference"
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
providerIds: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!attachedQuery.data) return;
|
||||||
|
const providers = attachedQuery.data.map((provider) => ({
|
||||||
|
id: String(provider.providerId),
|
||||||
|
text: provider.name
|
||||||
|
}));
|
||||||
|
setSelectedProviders(providers);
|
||||||
|
form.reset({
|
||||||
|
providerIds: attachedQuery.data.map((p) => p.providerId)
|
||||||
|
});
|
||||||
|
}, [attachedQuery.data, form]);
|
||||||
|
|
||||||
|
const [, formAction, saveLoading] = useActionState(async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
try {
|
||||||
|
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
|
||||||
|
providers: data.providerIds.map((providerId) => ({
|
||||||
|
providerId,
|
||||||
|
modelAccessMode: "catalog"
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
resourceQueries.siteResourceAiProviders({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("aiResourceProvidersUpdated")
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("aiResourceProvidersErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("aiResourceProvidersErrorUpdate")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
if (siteResource.mode !== "inference") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("aiResourceProviders")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("aiResourceProvidersDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
id="private-resource-providers-form"
|
||||||
|
>
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="providerIds"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"aiResourceProviders"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<AiProvidersSelector
|
||||||
|
orgId={
|
||||||
|
siteResource.orgId
|
||||||
|
}
|
||||||
|
selectedProviders={
|
||||||
|
selectedProviders
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
attachedQuery.isLoading ||
|
||||||
|
saveLoading
|
||||||
|
}
|
||||||
|
onSelectProviders={(
|
||||||
|
providers
|
||||||
|
) => {
|
||||||
|
setSelectedProviders(
|
||||||
|
providers
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"providerIds",
|
||||||
|
providers.map(
|
||||||
|
(p) =>
|
||||||
|
parseInt(
|
||||||
|
p.id,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
),
|
||||||
|
{
|
||||||
|
shouldValidate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"aiResourceProvidersHelp"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="private-resource-providers-form"
|
||||||
|
loading={saveLoading}
|
||||||
|
disabled={attachedQuery.isLoading}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ import {
|
|||||||
asAnySetValue,
|
asAnySetValue,
|
||||||
asAnyWatch
|
asAnyWatch
|
||||||
} from "@app/lib/formControlUtils";
|
} from "@app/lib/formControlUtils";
|
||||||
|
import {
|
||||||
|
AiProvidersSelector,
|
||||||
|
type SelectedAiProvider
|
||||||
|
} from "@app/components/AiProvidersSelector";
|
||||||
|
|
||||||
export default function CreatePrivateResourcePage() {
|
export default function CreatePrivateResourcePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -88,6 +92,9 @@ export default function CreatePrivateResourcePage() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
const [selectedProviders, setSelectedProviders] = useState<
|
||||||
|
SelectedAiProvider[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
@@ -112,7 +119,8 @@ export default function CreatePrivateResourcePage() {
|
|||||||
pamMode: "passthrough",
|
pamMode: "passthrough",
|
||||||
tcpPortRangeString: "*",
|
tcpPortRangeString: "*",
|
||||||
udpPortRangeString: "*",
|
udpPortRangeString: "*",
|
||||||
disableIcmp: false
|
disableIcmp: false,
|
||||||
|
providerIds: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,7 +204,9 @@ export default function CreatePrivateResourcePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.push(
|
router.push(
|
||||||
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
created.mode === "inference"
|
||||||
|
? `/${orgId}/settings/resources/private/${created.niceId}/general`
|
||||||
|
: `/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -336,6 +346,13 @@ export default function CreatePrivateResourcePage() {
|
|||||||
"destinationPort",
|
"destinationPort",
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
form.setValue(
|
||||||
|
"providerIds",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
setSelectedProviders(
|
||||||
|
[]
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"destinationPort",
|
"destinationPort",
|
||||||
@@ -637,6 +654,76 @@ export default function CreatePrivateResourcePage() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "inference" && (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("aiResourceProviders")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("aiResourceProvidersDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="providerIds"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"aiResourceProviders"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<AiProvidersSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedProviders={
|
||||||
|
selectedProviders
|
||||||
|
}
|
||||||
|
onSelectProviders={(
|
||||||
|
providers
|
||||||
|
) => {
|
||||||
|
setSelectedProviders(
|
||||||
|
providers
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"providerIds",
|
||||||
|
providers.map(
|
||||||
|
(
|
||||||
|
p
|
||||||
|
) =>
|
||||||
|
parseInt(
|
||||||
|
p.id,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
),
|
||||||
|
{
|
||||||
|
shouldValidate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"aiResourceProvidersHelp"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 mt-8">
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { aiProviderQueries } from "@app/lib/queries";
|
||||||
|
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
export type SelectedAiProvider = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiProvidersSelectorProps = {
|
||||||
|
orgId: string;
|
||||||
|
selectedProviders?: SelectedAiProvider[];
|
||||||
|
onSelectProviders: (providers: SelectedAiProvider[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersSelector({
|
||||||
|
orgId,
|
||||||
|
selectedProviders = [],
|
||||||
|
onSelectProviders,
|
||||||
|
disabled,
|
||||||
|
buttonText
|
||||||
|
}: AiProvidersSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedValue] = useDebounce(searchQuery, 150);
|
||||||
|
|
||||||
|
const { data: providers = [] } = useQuery(
|
||||||
|
aiProviderQueries.orgProviders({
|
||||||
|
orgId,
|
||||||
|
query: debouncedValue || undefined
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: SelectedAiProvider[] = providers
|
||||||
|
.filter((provider) => provider.enabled)
|
||||||
|
.map((provider) => ({
|
||||||
|
id: String(provider.providerId),
|
||||||
|
text: provider.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectTagInput
|
||||||
|
buttonText={buttonText ?? t("aiResourceProvidersSelect")}
|
||||||
|
emptyPlaceholder={t("aiResourceProvidersEmpty")}
|
||||||
|
searchPlaceholder={t("aiProvidersSearch")}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
options={options}
|
||||||
|
value={selectedProviders}
|
||||||
|
onChange={onSelectProviders}
|
||||||
|
onSearch={setSearchQuery}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export type PrivateResourceFormValues = {
|
|||||||
roles?: PrivateResourceAccessTag[];
|
roles?: PrivateResourceAccessTag[];
|
||||||
users?: PrivateResourceAccessTag[];
|
users?: PrivateResourceAccessTag[];
|
||||||
clients?: PrivateResourceClient[];
|
clients?: PrivateResourceClient[];
|
||||||
|
providerIds?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SiteResourceAccess = {
|
export type SiteResourceAccess = {
|
||||||
@@ -220,7 +221,11 @@ export function buildCreateSiteResourcePayload(
|
|||||||
typeof data.alias === "string" &&
|
typeof data.alias === "string" &&
|
||||||
data.alias.trim()
|
data.alias.trim()
|
||||||
? data.alias
|
? data.alias
|
||||||
: undefined
|
: undefined,
|
||||||
|
aiProviders: (data.providerIds ?? []).map((providerId) => ({
|
||||||
|
providerId,
|
||||||
|
modelAccessMode: "catalog" as const
|
||||||
|
}))
|
||||||
}),
|
}),
|
||||||
...((data.mode === "host" || data.mode === "cidr") && {
|
...((data.mode === "host" || data.mode === "cidr") && {
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
@@ -353,8 +358,12 @@ export function siteResourceToFormValues(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGeneralFormSchema(t: TranslateFn) {
|
export function createGeneralFormSchema(
|
||||||
return z.object({
|
t: TranslateFn,
|
||||||
|
options?: { requireAlias?: boolean }
|
||||||
|
) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, t("editInternalResourceDialogNameRequired"))
|
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||||
@@ -364,7 +373,17 @@ export function createGeneralFormSchema(t: TranslateFn) {
|
|||||||
.min(1)
|
.min(1)
|
||||||
.max(255)
|
.max(255)
|
||||||
.regex(/^[a-zA-Z0-9-]+$/),
|
.regex(/^[a-zA-Z0-9-]+$/),
|
||||||
enabled: z.boolean()
|
enabled: z.boolean(),
|
||||||
|
alias: z.string().nullish()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (options?.requireAlias && !data.alias?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("aiResourceAliasRequired"),
|
||||||
|
path: ["alias"]
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +441,8 @@ export function createCreateFormSchema(t: TranslateFn) {
|
|||||||
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
|
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
|
||||||
tcpPortRangeString: createPortRangeStringSchema(t),
|
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||||
udpPortRangeString: createPortRangeStringSchema(t),
|
udpPortRangeString: createPortRangeStringSchema(t),
|
||||||
disableIcmp: z.boolean().optional()
|
disableIcmp: z.boolean().optional(),
|
||||||
|
providerIds: z.array(z.number().int().positive()).optional()
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
const isNativeSsh =
|
const isNativeSsh =
|
||||||
@@ -434,12 +454,27 @@ export function createCreateFormSchema(t: TranslateFn) {
|
|||||||
) {
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t(
|
message: t("createInternalResourceDialogPleaseSelectSite"),
|
||||||
"createInternalResourceDialogPleaseSelectSite"
|
|
||||||
),
|
|
||||||
path: ["siteIds"]
|
path: ["siteIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (data.mode === "inference") {
|
||||||
|
const trimmedAlias = data.alias?.trim();
|
||||||
|
if (!trimmedAlias) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("aiResourceAliasRequired"),
|
||||||
|
path: ["alias"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.providerIds || data.providerIds.length < 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("aiResourceProvidersRequired"),
|
||||||
|
path: ["providerIds"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
data.mode !== "ssh" &&
|
data.mode !== "ssh" &&
|
||||||
data.mode !== "inference" &&
|
data.mode !== "inference" &&
|
||||||
@@ -592,9 +627,26 @@ export function createInferenceFormSchema(t: TranslateFn) {
|
|||||||
return z
|
return z
|
||||||
.object({
|
.object({
|
||||||
mode: z.literal("inference"),
|
mode: z.literal("inference"),
|
||||||
alias: z.string().nullish()
|
alias: z.string().nullish(),
|
||||||
|
providerIds: z.array(z.number().int().positive()).optional()
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
.superRefine((data, ctx) => {
|
||||||
|
const trimmedAlias = data.alias?.trim();
|
||||||
|
if (!trimmedAlias) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("aiResourceAliasRequired"),
|
||||||
|
path: ["alias"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.providerIds || data.providerIds.length < 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("aiResourceProvidersRequired"),
|
||||||
|
path: ["providerIds"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCidrFormSchema(t: TranslateFn) {
|
export function createCidrFormSchema(t: TranslateFn) {
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ import type {
|
|||||||
} from "@server/routers/siteResource";
|
} from "@server/routers/siteResource";
|
||||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||||
import type { ListTargetsResponse } from "@server/routers/target";
|
import type { ListTargetsResponse } from "@server/routers/target";
|
||||||
|
import type {
|
||||||
|
ListAiModelsResponse,
|
||||||
|
ListAiProvidersResponse
|
||||||
|
} from "@server/routers/aiProvider/types";
|
||||||
import type { ListUsersResponse } from "@server/routers/user";
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
import {
|
import {
|
||||||
@@ -1174,6 +1178,36 @@ export const aiProviderQueries = {
|
|||||||
|
|
||||||
return res.data.data.targets;
|
return res.data.data.targets;
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
providerModels: ({ providerId }: { providerId: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["AI_PROVIDERS", providerId, "MODELS"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListAiModelsResponse>
|
||||||
|
>(`/ai-provider/${providerId}/models`, {
|
||||||
|
params: { page: 1, pageSize: 1000 },
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data.models;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
orgProviders: ({ orgId, query }: { orgId: string; query?: string }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["AI_PROVIDERS", orgId, "LIST", query ?? ""] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListAiProvidersResponse>
|
||||||
|
>(`/org/${orgId}/ai-providers`, {
|
||||||
|
params: {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
...(query ? { query } : {})
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data.providers;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1242,6 +1276,30 @@ export const resourceQueries = {
|
|||||||
return res.data.data.clients;
|
return res.data.data.clients;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
siteResourceAiProviders: ({ siteResourceId }: { siteResourceId: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [
|
||||||
|
"SITE_RESOURCES",
|
||||||
|
siteResourceId,
|
||||||
|
"AI_PROVIDERS"
|
||||||
|
] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<{
|
||||||
|
providers: Array<{
|
||||||
|
providerId: number;
|
||||||
|
modelAccessMode: "catalog" | "allowlist";
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>;
|
||||||
|
}>
|
||||||
|
>(`/site-resource/${siteResourceId}/ai-providers`, {
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data.providers;
|
||||||
|
}
|
||||||
|
}),
|
||||||
resourceTargets: ({ resourceId }: { resourceId: number }) =>
|
resourceTargets: ({ resourceId }: { resourceId: number }) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,
|
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user