mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-05 20:21:19 +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?",
|
||||
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
||||
"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",
|
||||
"sidebarProvisioning": "Provisioning",
|
||||
"sidebarSettings": "Settings",
|
||||
|
||||
@@ -232,9 +232,9 @@ export const resourceAiProviders = pgTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: varchar("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||
);
|
||||
@@ -523,9 +523,9 @@ export const siteResourceAiProviders = pgTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: varchar("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
@@ -229,9 +229,9 @@ export const resourceAiProviders = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: text("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||
);
|
||||
@@ -508,9 +508,9 @@ export const siteResourceAiProviders = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelAccessMode: text("modelAccessMode")
|
||||
.$type<"passthrough" | "catalog" | "allowlist">()
|
||||
.$type<"catalog" | "allowlist">()
|
||||
.notNull()
|
||||
.default("passthrough")
|
||||
.default("catalog")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
@@ -13,11 +13,7 @@ import { z } from "zod";
|
||||
|
||||
type DbOrTrx = Transaction | typeof db;
|
||||
|
||||
export const modelAccessModeSchema = z.enum([
|
||||
"passthrough",
|
||||
"catalog",
|
||||
"allowlist"
|
||||
]);
|
||||
export const modelAccessModeSchema = z.enum(["catalog", "allowlist"]);
|
||||
|
||||
export type ModelAccessMode = z.infer<typeof modelAccessModeSchema>;
|
||||
|
||||
@@ -50,10 +46,7 @@ function normalizeAttachments(
|
||||
): ResourceAiProviderAttachment[] {
|
||||
const byProvider = new Map<number, ModelAccessMode>();
|
||||
for (const input of inputs) {
|
||||
byProvider.set(
|
||||
input.providerId,
|
||||
input.modelAccessMode ?? "passthrough"
|
||||
);
|
||||
byProvider.set(input.providerId, input.modelAccessMode ?? "catalog");
|
||||
}
|
||||
return [...byProvider.entries()].map(([providerId, modelAccessMode]) => ({
|
||||
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.
|
||||
* At most one passthrough provider is allowed per resource.
|
||||
*/
|
||||
export async function resolveProviderAttachments(input: {
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
@@ -119,6 +155,11 @@ export async function resolveProviderAttachments(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const overlapError = await assertNoOverlappingModelKeys(attachments);
|
||||
if (overlapError) {
|
||||
return overlapError;
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
|
||||
@@ -331,10 +331,6 @@ async function providerMatchesModel(
|
||||
requestedModel: string,
|
||||
allowedModelIds: number[]
|
||||
): Promise<boolean> {
|
||||
if (attachment.modelAccessMode === "passthrough") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [matchedModel] = await db
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
@@ -366,26 +362,7 @@ async function selectProvider(
|
||||
allowedModelIds: number[],
|
||||
requestedModel: string | undefined
|
||||
): Promise<ProviderSelection> {
|
||||
const passthroughAttachments = attachments.filter(
|
||||
(a) => a.modelAccessMode === "passthrough"
|
||||
);
|
||||
const hasRestricted = attachments.some(
|
||||
(a) =>
|
||||
a.modelAccessMode === "catalog" || a.modelAccessMode === "allowlist"
|
||||
);
|
||||
|
||||
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 {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
@@ -395,10 +372,6 @@ async function selectProvider(
|
||||
|
||||
const candidates: ProviderAttachment[] = [];
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.modelAccessMode === "passthrough") {
|
||||
candidates.push(attachment);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
await providerMatchesModel(
|
||||
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 {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
|
||||
@@ -109,7 +109,7 @@ const createHttpResourceSchema = z
|
||||
.array(resourceAiProviderAttachmentSchema)
|
||||
.optional()
|
||||
.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(
|
||||
@@ -397,9 +397,7 @@ async function createHttpResource(
|
||||
requireAtLeastOne: true
|
||||
});
|
||||
if (isInferenceFieldsError(resolved)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, resolved.error)
|
||||
);
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, resolved.error));
|
||||
}
|
||||
providerAttachments = resolved;
|
||||
} else if (aiProviderInputs && aiProviderInputs.length > 0) {
|
||||
|
||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/ai-providers",
|
||||
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],
|
||||
request: {
|
||||
params: setResourceAiProvidersParamsSchema,
|
||||
@@ -116,7 +116,9 @@ export async function setResourceAiProviders(
|
||||
requireAtLeastOne: true
|
||||
});
|
||||
if (isInferenceFieldsError(attachments)) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||
);
|
||||
}
|
||||
|
||||
await setPublicResourceAiProviders(resourceId, attachments);
|
||||
|
||||
@@ -90,7 +90,7 @@ const createSiteResourceSchema = z
|
||||
.array(resourceAiProviderAttachmentSchema)
|
||||
.optional()
|
||||
.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()
|
||||
|
||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/site-resource/{siteResourceId}/ai-providers",
|
||||
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],
|
||||
request: {
|
||||
params: setSiteResourceAiProvidersParamsSchema,
|
||||
@@ -118,7 +118,9 @@ export async function setSiteResourceAiProviders(
|
||||
requireAtLeastOne: true
|
||||
});
|
||||
if (isInferenceFieldsError(attachments)) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, attachments.error));
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, attachments.error)
|
||||
);
|
||||
}
|
||||
|
||||
await replaceAttachments(siteResourceId, attachments);
|
||||
|
||||
@@ -69,6 +69,10 @@ export default async function AiProviderLayout({ children, params }: Props) {
|
||||
title: t("aiProviderNetworkSettings"),
|
||||
href: "/{orgId}/settings/ai-providers/{providerId}/network"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderModels"),
|
||||
href: "/{orgId}/settings/ai-providers/{providerId}/models"
|
||||
},
|
||||
{
|
||||
title: t("aiProviderAuthSettings"),
|
||||
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";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { asAnyControl, asAnyWatch } from "@app/lib/formControlUtils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo } from "react";
|
||||
@@ -35,8 +37,12 @@ import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
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>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
@@ -44,7 +50,8 @@ export default function PrivateResourceGeneralPage() {
|
||||
defaultValues: {
|
||||
name: siteResource.name,
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled
|
||||
enabled: siteResource.enabled,
|
||||
alias: siteResource.alias ?? null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,7 +63,13 @@ export default function PrivateResourceGeneralPage() {
|
||||
await save({
|
||||
name: data.name,
|
||||
niceId: data.niceId,
|
||||
enabled: data.enabled
|
||||
enabled: data.enabled,
|
||||
...(isInference
|
||||
? {
|
||||
mode: "inference" as const,
|
||||
alias: data.alias
|
||||
}
|
||||
: {})
|
||||
});
|
||||
}, null);
|
||||
|
||||
@@ -152,6 +165,17 @@ export default function PrivateResourceGeneralPage() {
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{isInference && (
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="edit"
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,105 +1,15 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
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 const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export default function PrivateResourceInferencePage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
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>
|
||||
export default async function PrivateResourceInferencePage(props: {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/resources/private/${params.niceId}/providers`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,20 +55,37 @@ export default async function PrivateResourceLayout(
|
||||
| "sshSettings"
|
||||
| "inferenceSettings";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
];
|
||||
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"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
asAnyWatch
|
||||
} from "@app/lib/formControlUtils";
|
||||
import {
|
||||
AiProvidersSelector,
|
||||
type SelectedAiProvider
|
||||
} from "@app/components/AiProvidersSelector";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
@@ -88,6 +92,9 @@ export default function CreatePrivateResourcePage() {
|
||||
: null;
|
||||
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [selectedProviders, setSelectedProviders] = useState<
|
||||
SelectedAiProvider[]
|
||||
>([]);
|
||||
|
||||
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -112,7 +119,8 @@ export default function CreatePrivateResourcePage() {
|
||||
pamMode: "passthrough",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false
|
||||
disableIcmp: false,
|
||||
providerIds: []
|
||||
}
|
||||
});
|
||||
|
||||
@@ -196,7 +204,9 @@ export default function CreatePrivateResourcePage() {
|
||||
}
|
||||
|
||||
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) {
|
||||
toast({
|
||||
@@ -336,6 +346,13 @@ export default function CreatePrivateResourcePage() {
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"providerIds",
|
||||
[]
|
||||
);
|
||||
setSelectedProviders(
|
||||
[]
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
@@ -637,6 +654,76 @@ export default function CreatePrivateResourcePage() {
|
||||
</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">
|
||||
<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[];
|
||||
users?: PrivateResourceAccessTag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
providerIds?: number[];
|
||||
};
|
||||
|
||||
export type SiteResourceAccess = {
|
||||
@@ -220,7 +221,11 @@ export function buildCreateSiteResourcePayload(
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined
|
||||
: undefined,
|
||||
aiProviders: (data.providerIds ?? []).map((providerId) => ({
|
||||
providerId,
|
||||
modelAccessMode: "catalog" as const
|
||||
}))
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
@@ -353,19 +358,33 @@ export function siteResourceToFormValues(
|
||||
};
|
||||
}
|
||||
|
||||
export function createGeneralFormSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
niceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9-]+$/),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
export function createGeneralFormSchema(
|
||||
t: TranslateFn,
|
||||
options?: { requireAlias?: boolean }
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
niceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9-]+$/),
|
||||
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"]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createAccessFormSchema() {
|
||||
@@ -422,7 +441,8 @@ export function createCreateFormSchema(t: TranslateFn) {
|
||||
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
|
||||
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||
udpPortRangeString: createPortRangeStringSchema(t),
|
||||
disableIcmp: z.boolean().optional()
|
||||
disableIcmp: z.boolean().optional(),
|
||||
providerIds: z.array(z.number().int().positive()).optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const isNativeSsh =
|
||||
@@ -434,12 +454,27 @@ export function createCreateFormSchema(t: TranslateFn) {
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t(
|
||||
"createInternalResourceDialogPleaseSelectSite"
|
||||
),
|
||||
message: t("createInternalResourceDialogPleaseSelectSite"),
|
||||
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 (
|
||||
data.mode !== "ssh" &&
|
||||
data.mode !== "inference" &&
|
||||
@@ -592,9 +627,26 @@ export function createInferenceFormSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
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) {
|
||||
|
||||
@@ -57,6 +57,10 @@ import type {
|
||||
} from "@server/routers/siteResource";
|
||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||
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 ResponseT from "@server/types/Response";
|
||||
import {
|
||||
@@ -1174,6 +1178,36 @@ export const aiProviderQueries = {
|
||||
|
||||
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;
|
||||
}
|
||||
}),
|
||||
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 }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "TARGETS"] as const,
|
||||
|
||||
Reference in New Issue
Block a user