From 98f5e39a7f9a6d72ce36076c9e2749387d562dad Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 11 Aug 2026 14:41:07 -0400 Subject: [PATCH] show ai gateway resource details in launcher --- messages/en-US.json | 11 + server/lib/aiInferenceResource.ts | 146 ++++++++++++ server/routers/external.ts | 14 ++ .../routers/launcher/formatLauncherAccess.ts | 23 +- server/routers/launcher/index.ts | 5 + .../launcher/launcherResourceAccess.ts | 143 +++++++---- .../routers/launcher/listLauncherAiModels.ts | 172 ++++++++++++++ server/routers/launcher/types.ts | 1 + .../resources/private/create/page.tsx | 91 +++---- src/components/PrivateResourceInfoBox.tsx | 8 +- .../LauncherGroupSection.tsx | 9 +- .../LauncherGroupTrigger.tsx | 17 +- .../LauncherInferenceApiKeysSection.tsx | 223 ++++++++++++++++++ .../LauncherInferenceModelsSection.tsx | 170 +++++++++++++ .../LauncherResourcePanel.tsx | 68 +++++- src/lib/launcherResourceAccess.ts | 20 +- src/lib/launcherResourceDetails.ts | 6 +- src/lib/queries.ts | 66 ++++++ 18 files changed, 1069 insertions(+), 124 deletions(-) create mode 100644 server/routers/launcher/listLauncherAiModels.ts create mode 100644 src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx create mode 100644 src/components/resource-launcher/LauncherInferenceModelsSection.tsx diff --git a/messages/en-US.json b/messages/en-US.json index c7db842a7..0eaf60378 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4053,7 +4053,18 @@ "resourceLauncherTcp": "TCP", "resourceLauncherUdp": "UDP", "resourceLauncherUnlabeled": "Unlabeled", + "resourceLauncherAiGateway": "AI Gateway", "resourceLauncherNoSite": "No Site", + "resourceLauncherAvailableModels": "Available Models", + "resourceLauncherAvailableModelsDescription": "Models you can use with this inference resource.", + "resourceLauncherAvailableModelsEmpty": "No models are available for this resource.", + "resourceLauncherAvailableModelsError": "Could not load available models.", + "resourceLauncherApiKeys": "API Keys", + "resourceLauncherApiKeysDescription": "Use your identity key or an attributed key to authenticate with this resource.", + "resourceLauncherApiKeysIdentity": "Identity Key", + "resourceLauncherApiKeysManual": "Attributed Keys", + "resourceLauncherApiKeysEmpty": "No API keys are available for this resource.", + "resourceLauncherApiKeysError": "Could not load API keys.", "resourceLauncherNoResourcesInGroup": "No resources in this group", "resourceLauncherEmptyStateTitle": "No Resources Available", "resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.", diff --git a/server/lib/aiInferenceResource.ts b/server/lib/aiInferenceResource.ts index 92a438ac2..cfd560fce 100644 --- a/server/lib/aiInferenceResource.ts +++ b/server/lib/aiInferenceResource.ts @@ -518,6 +518,152 @@ export async function listSiteResourceAiProviders(siteResourceId: number) { .where(eq(siteResourceAiProviders.siteResourceId, siteResourceId)); } +export type EffectiveAllowModel = { + modelId: number; + modelKey: string; + name: string; + providerId: number; + providerName: string; +}; + +export async function listEffectiveAllowModels(options: { + resourceId?: number; + siteResourceId?: number; +}): Promise { + if ( + options.resourceId === undefined && + options.siteResourceId === undefined + ) { + return []; + } + + const attachments = + options.resourceId !== undefined + ? await listPublicResourceAiProviders(options.resourceId) + : await listSiteResourceAiProviders(options.siteResourceId!); + + const activeAttachments = attachments.filter( + (a) => a.enabled && a.providerEnabled + ); + if (activeAttachments.length === 0) { + return []; + } + + const inheritProviderIds = activeAttachments + .filter((a) => a.accessMode === "inherit") + .map((a) => a.providerId); + const selectProviderIds = activeAttachments + .filter((a) => a.accessMode === "select") + .map((a) => a.providerId); + + const providerNameById = new Map( + activeAttachments.map((a) => [a.providerId, a.name] as const) + ); + + const models: EffectiveAllowModel[] = []; + + if (inheritProviderIds.length > 0) { + const rows = await db + .select({ + modelId: aiModels.modelId, + modelKey: aiModels.modelKey, + name: aiModels.name, + providerId: aiModels.providerId + }) + .from(aiModels) + .where( + and( + inArray(aiModels.providerId, inheritProviderIds), + eq(aiModels.enabled, true), + eq(aiModels.listType, "allow") + ) + ); + for (const row of rows) { + models.push({ + ...row, + providerName: providerNameById.get(row.providerId) ?? "" + }); + } + } + + if (selectProviderIds.length > 0) { + if (options.resourceId !== undefined) { + const rows = await db + .select({ + modelId: aiModels.modelId, + modelKey: aiModels.modelKey, + name: aiModels.name, + providerId: aiModels.providerId + }) + .from(resourceAiModels) + .innerJoin( + aiModels, + eq(resourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq(resourceAiModels.resourceId, options.resourceId), + inArray(aiModels.providerId, selectProviderIds), + eq(resourceAiModels.listType, "allow"), + eq(aiModels.enabled, true) + ) + ); + for (const row of rows) { + models.push({ + ...row, + providerName: providerNameById.get(row.providerId) ?? "" + }); + } + } else if (options.siteResourceId !== undefined) { + const rows = await db + .select({ + modelId: aiModels.modelId, + modelKey: aiModels.modelKey, + name: aiModels.name, + providerId: aiModels.providerId + }) + .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq( + siteResourceAiModels.siteResourceId, + options.siteResourceId + ), + inArray(aiModels.providerId, selectProviderIds), + eq(siteResourceAiModels.listType, "allow"), + eq(aiModels.enabled, true) + ) + ); + for (const row of rows) { + models.push({ + ...row, + providerName: providerNameById.get(row.providerId) ?? "" + }); + } + } + } + + models.sort((a, b) => { + const byProvider = a.providerName.localeCompare( + b.providerName, + undefined, + { + sensitivity: "base" + } + ); + if (byProvider !== 0) { + return byProvider; + } + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); + + return models; +} + /** * Model list APIs require an inference resource with at least one select-mode * attached provider. diff --git a/server/routers/external.ts b/server/routers/external.ts index 141660f0f..9c2f38e3d 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -590,6 +590,20 @@ authenticated.get( launcher.listLauncherResources ); +authenticated.get( + "/org/:orgId/launcher/resource/:resourceId/ai-models", + verifyOrgAccess, + verifyResourceAccess, + launcher.listLauncherPublicAiModels +); + +authenticated.get( + "/org/:orgId/launcher/site-resource/:siteResourceId/ai-models", + verifyOrgAccess, + verifySiteResourceAccess, + launcher.listLauncherSiteAiModels +); + authenticated.get( "/org/:orgId/launcher/sites", verifyOrgAccess, diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts index ff5a5bf37..ca2104ccd 100644 --- a/server/routers/launcher/formatLauncherAccess.ts +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -98,7 +98,7 @@ function formatTcpUdpResourceAccess( export function formatPublicResourceAccess( resource: PublicResourceAccessInput ): LauncherAccessFields { - const browserModes = ["http", "ssh", "rdp", "vnc"]; + const browserModes = ["http", "ssh", "rdp", "vnc", "inference"]; if (!browserModes.includes(resource.mode)) { return formatTcpUdpResourceAccess( resource.exitNodeEndpoint, @@ -125,15 +125,10 @@ export function formatPublicResourceAccess( export function formatSiteResourceAccess( resource: SiteResourceAccessInput ): LauncherAccessFields { - if (resource.alias) { - return { - accessDisplay: resource.alias, - accessCopyValue: resource.alias, - accessUrl: null - }; - } - - if (resource.mode === "http" && resource.fullDomain) { + if ( + (resource.mode === "http" || resource.mode === "inference") && + resource.fullDomain + ) { const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; return { accessDisplay: url, @@ -142,6 +137,14 @@ export function formatSiteResourceAccess( }; } + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + const destination = formatSiteResourceDestinationDisplay({ mode: resource.mode as SiteResourceDestinationInput["mode"], destination: resource.destination, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index 1c3fed44c..db92f783d 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -5,6 +5,11 @@ export { listLauncherResources } from "./listLauncherResources"; export { listLauncherSites } from "./listLauncherSites"; export { listLauncherLabels } from "./listLauncherLabels"; export { listLauncherViews } from "./listLauncherViews"; +export { + listLauncherPublicAiModels, + listLauncherSiteAiModels +} from "./listLauncherAiModels"; +export type { ListLauncherAiModelsResponse } from "./listLauncherAiModels"; export { createLauncherView } from "./createLauncherView"; export { updateLauncherView } from "./updateLauncherView"; export { deleteLauncherView } from "./deleteLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index ce94a7a50..386a6b1a7 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -31,6 +31,7 @@ import { inArray, isNull, like, + ne, or, sql, type SQL @@ -40,6 +41,7 @@ import { formatSiteResourceAccess } from "./formatLauncherAccess"; import { + LAUNCHER_AI_GATEWAY_GROUP_KEY, LAUNCHER_FLAT_GROUP_KEY, LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, @@ -652,6 +654,7 @@ async function listSiteGroups( } } + let aiGatewayCount = 0; let noSiteCount = 0; if (accessible.resourceIds.length > 0 && siteFilterIds.length === 0) { @@ -665,27 +668,49 @@ async function listSiteGroups( noSitePublicConditions.push(searchPublic); } - let noSitePublicQuery = db - .select({ - itemCount: countDistinct(resources.resourceId) - }) - .from(resources) - .leftJoin(targets, eq(targets.resourceId, resources.resourceId)); + const buildNoSitePublicQuery = () => { + let queryBuilder = db + .select({ + itemCount: countDistinct(resources.resourceId) + }) + .from(resources) + .leftJoin( + targets, + eq(targets.resourceId, resources.resourceId) + ); + + if (labelFilterIds.length > 0) { + queryBuilder = queryBuilder.innerJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ); + } + + return queryBuilder; + }; if (labelFilterIds.length > 0) { - noSitePublicQuery = noSitePublicQuery.innerJoin( - resourceLabels, - eq(resourceLabels.resourceId, resources.resourceId) - ); noSitePublicConditions.push( inArray(resourceLabels.labelId, labelFilterIds) ); } - const [noSitePublicRow] = await noSitePublicQuery.where( - and(...noSitePublicConditions, isNull(targets.targetId)) + const [aiGatewayPublicRow] = await buildNoSitePublicQuery().where( + and( + ...noSitePublicConditions, + isNull(targets.targetId), + eq(resources.mode, "inference") + ) + ); + const [noSitePublicRow] = await buildNoSitePublicQuery().where( + and( + ...noSitePublicConditions, + isNull(targets.targetId), + ne(resources.mode, "inference") + ) ); + aiGatewayCount += Number(aiGatewayPublicRow?.itemCount ?? 0); noSiteCount += Number(noSitePublicRow?.itemCount ?? 0); } @@ -700,38 +725,57 @@ async function listSiteGroups( noSiteSiteConditions.push(searchSite); } - let noSiteSiteQuery = db - .select({ - itemCount: countDistinct(siteResources.siteResourceId) - }) - .from(siteResources) - .leftJoin( - siteNetworks, - eq(siteResources.networkId, siteNetworks.networkId) - ) - .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + const buildNoSiteSiteQuery = () => { + let queryBuilder = db + .select({ + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + queryBuilder = queryBuilder.innerJoin( + siteResourceLabels, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ); + } + + return queryBuilder; + }; if (labelFilterIds.length > 0) { - noSiteSiteQuery = noSiteSiteQuery.innerJoin( - siteResourceLabels, - eq( - siteResourceLabels.siteResourceId, - siteResources.siteResourceId - ) - ); noSiteSiteConditions.push( inArray(siteResourceLabels.labelId, labelFilterIds) ); } - const [noSiteSiteRow] = await noSiteSiteQuery.where( - and(...noSiteSiteConditions, isNull(sites.siteId)) + const [aiGatewaySiteRow] = await buildNoSiteSiteQuery().where( + and( + ...noSiteSiteConditions, + isNull(sites.siteId), + eq(siteResources.mode, "inference") + ) + ); + const [noSiteSiteRow] = await buildNoSiteSiteQuery().where( + and( + ...noSiteSiteConditions, + isNull(sites.siteId), + ne(siteResources.mode, "inference") + ) ); + aiGatewayCount += Number(aiGatewaySiteRow?.itemCount ?? 0); noSiteCount += Number(noSiteSiteRow?.itemCount ?? 0); } - let groups: LauncherGroup[] = Array.from(siteCountMap.values()).map( + const siteGroups: LauncherGroup[] = Array.from(siteCountMap.values()).map( (row) => ({ groupKey: String(row.siteId), name: row.name, @@ -742,8 +786,26 @@ async function listSiteGroups( }) ); + siteGroups.sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return query.order === "desc" ? -cmp : cmp; + }); + + const pinnedGroups: LauncherGroup[] = []; + + if (aiGatewayCount > 0 && siteFilterIds.length === 0) { + pinnedGroups.push({ + groupKey: LAUNCHER_AI_GATEWAY_GROUP_KEY, + name: "AI Gateway", + groupType: "site", + itemCount: aiGatewayCount + }); + } + if (noSiteCount > 0 && siteFilterIds.length === 0) { - groups.push({ + pinnedGroups.push({ groupKey: LAUNCHER_NO_SITE_GROUP_KEY, name: "No Site", groupType: "site", @@ -751,12 +813,7 @@ async function listSiteGroups( }); } - groups.sort((a, b) => { - const cmp = a.name.localeCompare(b.name, undefined, { - sensitivity: "base" - }); - return query.order === "desc" ? -cmp : cmp; - }); + const groups = [...pinnedGroups, ...siteGroups]; const total = groups.length; return { @@ -1189,8 +1246,11 @@ function filterResourcesBySite( items: LauncherResource[], groupKey: string ): LauncherResource[] { + if (groupKey === LAUNCHER_AI_GATEWAY_GROUP_KEY) { + return items.filter((item) => item.mode === "inference"); + } if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) { - return items.filter((item) => !item.site); + return items.filter((item) => !item.site && item.mode !== "inference"); } const siteId = Number.parseInt(groupKey, 10); if (!Number.isFinite(siteId)) { @@ -1327,7 +1387,8 @@ async function listLauncherResourcesForUserUncached( const parsedSiteId = query.groupBy === "site" && - query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY + query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY && + query.groupKey !== LAUNCHER_AI_GATEWAY_GROUP_KEY ? Number.parseInt(query.groupKey, 10) : Number.NaN; const siteIdFilter = Number.isFinite(parsedSiteId) diff --git a/server/routers/launcher/listLauncherAiModels.ts b/server/routers/launcher/listLauncherAiModels.ts new file mode 100644 index 000000000..1f4258a8c --- /dev/null +++ b/server/routers/launcher/listLauncherAiModels.ts @@ -0,0 +1,172 @@ +import { db, resources, siteResources } from "@server/db"; +import { listEffectiveAllowModels } from "@server/lib/aiInferenceResource"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; + +const publicParamsSchema = z.strictObject({ + orgId: z.string().min(1), + resourceId: z.coerce.number().int().positive() +}); + +const siteParamsSchema = z.strictObject({ + orgId: z.string().min(1), + siteResourceId: z.coerce.number().int().positive() +}); + +export type ListLauncherAiModelsResponse = { + models: Awaited>; +}; + +export async function listLauncherPublicAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = publicParamsSchema.safeParse(req.params); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { resourceId } = parsed.data; + + const [resource] = await db + .select({ + resourceId: resources.resourceId, + mode: resources.mode + }) + .from(resources) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource || resource.mode !== "inference") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI models are only available for inference resources" + ) + ); + } + + const models = await listEffectiveAllowModels({ resourceId }); + return response(res, { + data: { models }, + success: true, + error: false, + message: "Launcher AI models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher AI models:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} + +export async function listLauncherSiteAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = siteParamsSchema.safeParse(req.params); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { siteResourceId } = parsed.data; + + const siteResource = + req.siteResource ?? + ( + await db + .select({ + siteResourceId: siteResources.siteResourceId, + mode: siteResources.mode, + orgId: siteResources.orgId + }) + .from(siteResources) + .where( + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.orgId, orgId) + ) + ) + .limit(1) + )[0]; + + if ( + !siteResource || + siteResource.orgId !== orgId || + siteResource.mode !== "inference" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI models are only available for inference resources" + ) + ); + } + + const models = await listEffectiveAllowModels({ siteResourceId }); + return response(res, { + data: { models }, + success: true, + error: false, + message: "Launcher AI models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher AI models:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index f235e824d..7084781e9 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; +export const LAUNCHER_AI_GATEWAY_GROUP_KEY = "ai-gateway"; export const LAUNCHER_FLAT_GROUP_KEY = "__all__"; export const launcherViewConfigSchema = z.object({ diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index a1b04f5e4..90f115bfd 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -368,56 +368,65 @@ export default function CreatePrivateResourcePage() { /> - {mode === "http" || - (mode === "inference" && ( - - - { - if (!res) { + {(mode === "http" || + mode === "inference") && ( + + ( + + { + if (!res) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } form.setValue( "httpConfigSubdomain", - null + res.subdomain ?? + null ); form.setValue( "httpConfigDomainId", - null + res.domainId, + { + shouldValidate: true + } ); form.setValue( "httpConfigFullDomain", - null + res.fullDomain ); - return; - } - form.setValue( - "httpConfigSubdomain", - res.subdomain ?? - null - ); - form.setValue( - "httpConfigDomainId", - res.domainId - ); - form.setValue( - "httpConfigFullDomain", - res.fullDomain - ); - }} - /> - - - {t( - "resourceDomainDescription" - )} - - - - ))} + }} + /> + + + {t( + "resourceDomainDescription" + )} + + + )} + /> + + )} {(mode === "host" || (mode === "ssh" && !isNativeSsh)) && ( diff --git a/src/components/PrivateResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx index fda9a7596..90ade3334 100644 --- a/src/components/PrivateResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -124,13 +124,17 @@ export function PrivateResourceInfoSections({ siteResource.fullDomain && build != "oss" ); + const showPortRestrictions = + isPanel && + siteResource.mode !== "http" && + siteResource.mode !== "inference"; const numSections = 2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (showCertificate ? 1 : 0) + - (isPanel ? 1 : 0); + (showPortRestrictions ? 1 : 0); const sections = ( @@ -194,7 +198,7 @@ export function PrivateResourceInfoSections({ ) : null} - {isPanel ? ( + {showPortRestrictions ? ( {t("portRestrictions")} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 49b648032..91320b383 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -17,6 +17,7 @@ import type { LauncherViewConfig } from "@server/routers/launcher/types"; import { + LAUNCHER_AI_GATEWAY_GROUP_KEY, LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY } from "@server/routers/launcher/types"; @@ -148,9 +149,11 @@ export function LauncherGroupSection({ const groupTitle = group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY ? t("resourceLauncherUnlabeled") - : group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY - ? t("resourceLauncherNoSite") - : group.name; + : group.groupKey === LAUNCHER_AI_GATEWAY_GROUP_KEY + ? t("resourceLauncherAiGateway") + : group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY + ? t("resourceLauncherNoSite") + : group.name; return ( ; + return ( - {group.groupType === "site" || group.groupType === "label" ? ( - - ) : null} + {statusDot} {title} ({group.itemCount}) diff --git a/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx new file mode 100644 index 000000000..8a71a07db --- /dev/null +++ b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx @@ -0,0 +1,223 @@ +"use client"; + +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { launcherQueries } from "@app/lib/queries"; +import type { + GetMyVirtualApiKeyResponse, + VirtualApiKeyWithResources +} from "@server/routers/virtualApiKey/types"; +import { useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type LauncherInferenceApiKeysSectionProps = { + orgId: string; + resourceGuid: string; +}; + +function keyPreview(virtualApiKeyId: string, lastChars: string): string { + return `vk-${virtualApiKeyId}••••${lastChars}`; +} + +function useRevealSecret(orgId: string, virtualApiKeyId: string) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [credential, setCredential] = useState(null); + const [loading, setLoading] = useState(false); + + const revealSecret = () => { + if (credential || loading) { + return; + } + + setLoading(true); + api.get>( + `/org/${orgId}/my-virtual-api-keys/${virtualApiKeyId}` + ) + .then((res) => { + const secret = res.data.data.virtualApiKey.secret; + if (secret) { + setCredential(`vk-${virtualApiKeyId}.${secret}`); + } else { + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: t( + "virtualApiKeysErrorFetchSecretDescription" + ) + }); + } + }) + .catch((e) => { + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorFetchSecretDescription") + ) + }); + }) + .finally(() => { + setLoading(false); + }); + }; + + return { credential, loading, revealSecret }; +} + +function PanelKeySecret({ + orgId, + virtualApiKeyId, + lastChars +}: { + orgId: string; + virtualApiKeyId: string; + lastChars: string; +}) { + const t = useTranslations(); + const preview = keyPreview(virtualApiKeyId, lastChars); + const { credential, loading, revealSecret } = useRevealSecret( + orgId, + virtualApiKeyId + ); + const displayValue = credential ?? preview; + + return ( +
+
+ +
+ {!credential ? ( + + ) : null} +
+ ); +} + +function ManualKeyRow({ + orgId, + keyRow +}: { + orgId: string; + keyRow: VirtualApiKeyWithResources; +}) { + const t = useTranslations(); + + return ( +
+

+ {keyRow.name || t("myVirtualApiKeysUnnamed")} +

+ {keyRow.description ? ( +

+ {keyRow.description} +

+ ) : null} + +
+ ); +} + +export function LauncherInferenceApiKeysSection({ + orgId, + resourceGuid +}: LauncherInferenceApiKeysSectionProps) { + const t = useTranslations(); + const { data, isPending, isError } = useQuery( + launcherQueries.myVirtualApiKeys(orgId, resourceGuid) + ); + + return ( + + + + {t("resourceLauncherApiKeys")} + + + {t("resourceLauncherApiKeysDescription")} + + + + {isPending ? ( +
+ +
+ ) : null} + {isError ? ( +

+ {t("resourceLauncherApiKeysError")} +

+ ) : null} + {!isPending && !isError && data ? ( +
+
+

+ {t("resourceLauncherApiKeysIdentity")} +

+ +
+ {data.manualKeys.length > 0 ? ( +
+ + + {t("resourceLauncherApiKeysManual")} + + + {t( + "myVirtualApiKeysManualResourceDescription" + )} + + +
+ {data.manualKeys.map((keyRow) => ( + + ))} +
+
+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherInferenceModelsSection.tsx b/src/components/resource-launcher/LauncherInferenceModelsSection.tsx new file mode 100644 index 000000000..510cce099 --- /dev/null +++ b/src/components/resource-launcher/LauncherInferenceModelsSection.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { launcherQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +const COLLAPSED_ROWS = 5; +const GRID_COLUMNS = 2; + +type LauncherInferenceModelsSectionProps = { + orgId: string; + params: + | { + resourceType: "public"; + resourceId: number; + } + | { + resourceType: "site"; + siteResourceId: number; + }; +}; + +export function LauncherInferenceModelsSection({ + orgId, + params +}: LauncherInferenceModelsSectionProps) { + const t = useTranslations(); + const { data, isPending, isError } = useQuery( + launcherQueries.aiModels(orgId, params) + ); + const models = data?.models ?? []; + const [listExpanded, setListExpanded] = useState(false); + const [clipHeight, setClipHeight] = useState(null); + const gridRef = useRef(null); + + const collapsedLimit = GRID_COLUMNS * COLLAPSED_ROWS; + const hasOverflow = models.length > collapsedLimit; + const isCollapsed = hasOverflow && !listExpanded; + + useEffect(() => { + if (!hasOverflow) { + setListExpanded(false); + } + }, [hasOverflow]); + + useLayoutEffect(() => { + if (!isCollapsed || !gridRef.current) { + setClipHeight(null); + return; + } + + const children = Array.from(gridRef.current.children) as HTMLElement[]; + const lastVisible = children[collapsedLimit - 1]; + if (!lastVisible) { + setClipHeight(null); + return; + } + + const gridTop = gridRef.current.getBoundingClientRect().top; + const cardBottom = lastVisible.getBoundingClientRect().bottom; + // Peek slightly into the next row so the fade has content to soften. + setClipHeight(cardBottom - gridTop + 12); + }, [isCollapsed, collapsedLimit, models]); + + return ( + + + + {t("resourceLauncherAvailableModels")} + + + {t("resourceLauncherAvailableModelsDescription")} + + + + {isPending ? ( +
+ +
+ ) : null} + {isError ? ( +

+ {t("resourceLauncherAvailableModelsError")} +

+ ) : null} + {!isPending && !isError && models.length === 0 ? ( +

+ {t("resourceLauncherAvailableModelsEmpty")} +

+ ) : null} + {!isPending && !isError && models.length > 0 ? ( +
+
+
+ {models.map((model) => ( +
+ + {model.modelKey} + + {model.providerName ? ( + + {model.providerName} + + ) : null} +
+ ))} +
+ {isCollapsed ? ( +
+ ) : null} +
+ {isCollapsed ? ( +
+ +
+ ) : null} + {hasOverflow && listExpanded ? ( +
+ +
+ ) : null} +
+ ) : null} + + + ); +} diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx index fb948bbcc..999d1bc03 100644 --- a/src/components/resource-launcher/LauncherResourcePanel.tsx +++ b/src/components/resource-launcher/LauncherResourcePanel.tsx @@ -8,6 +8,8 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import { PrivateResourceInfoSections } from "@app/components/PrivateResourceInfoBox"; +import { LauncherInferenceApiKeysSection } from "@app/components/resource-launcher/LauncherInferenceApiKeysSection"; +import { LauncherInferenceModelsSection } from "@app/components/resource-launcher/LauncherInferenceModelsSection"; import { SettingsSection, SettingsSectionBody, @@ -146,7 +148,8 @@ function HealthStatusDisplay({ ); } -const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"]; +const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"]; +const PUBLIC_AUTH_BADGE_MODES = [...PUBLIC_AUTH_METHODS_MODES, "inference"]; function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) { const t = useTranslations(); @@ -227,20 +230,24 @@ function PublicResourceAuthMethods({ } function PublicResourceDetails({ + orgId, launcherResource, resource, authInfo }: { + orgId: string; launcherResource: LauncherResource; resource: GetResourceResponse; authInfo: GetResourceAuthInfoResponse; }) { const t = useTranslations(); - const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes( - resource.mode || "" - ); + const mode = resource.mode || ""; + const isInference = mode === "inference"; + const showAuthBadge = PUBLIC_AUTH_BADGE_MODES.includes(mode); + const showAuthMethods = PUBLIC_AUTH_METHODS_MODES.includes(mode); + const showHealth = !isInference; const authState = derivePublicAuthState(resource.mode, authInfo); - const infoSectionCount = supportsAuth ? 4 : 3; + const infoSectionCount = 2 + (showAuthBadge ? 1 : 0) + (showHealth ? 1 : 0); return (
@@ -275,7 +282,7 @@ function PublicResourceDetails({ /> - {supportsAuth ? ( + {showAuthBadge ? ( {t("authentication")} @@ -295,30 +302,54 @@ function PublicResourceDetails({ ) : null} - - {t("health")} - - - - + {showHealth ? ( + + + {t("health")} + + + + + + ) : null} - {supportsAuth ? ( + {showAuthMethods ? ( ) : null} + {isInference ? ( + <> + + + + ) : null}
); } function PrivateResourceDetails({ + orgId, launcherResource, resource }: { + orgId: string; launcherResource: LauncherResource; resource: GetSiteResourceResponse; }) { const t = useTranslations(); + const isInference = resource.mode === "inference"; return (
@@ -365,6 +396,15 @@ function PrivateResourceDetails({ /> + {isInference ? ( + + ) : null}
); } @@ -405,6 +445,7 @@ function LauncherResourcePanelBody({ if (detail.resourceType === "public") { return ( diff --git a/src/lib/launcherResourceAccess.ts b/src/lib/launcherResourceAccess.ts index 6a208ef7e..f779bfec6 100644 --- a/src/lib/launcherResourceAccess.ts +++ b/src/lib/launcherResourceAccess.ts @@ -37,7 +37,7 @@ export type LauncherAccessFields = { export function formatPublicResourceAccess( resource: PublicResourceAccessInput ): LauncherAccessFields { - const browserModes = ["http", "ssh", "rdp", "vnc"]; + const browserModes = ["http", "ssh", "rdp", "vnc", "inference"]; if (!browserModes.includes(resource.mode)) { const port = resource.proxyPort?.toString() ?? ""; return { @@ -66,16 +66,8 @@ export function formatPublicResourceAccess( export function formatSiteResourceAccess( resource: SiteResourceAccessInput ): LauncherAccessFields { - if (resource.alias) { - return { - accessDisplay: resource.alias, - accessCopyValue: resource.alias, - accessUrl: null - }; - } - if ( - (resource.mode === "http" || resource.mode == "inference") && + (resource.mode === "http" || resource.mode === "inference") && resource.fullDomain ) { const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; @@ -86,6 +78,14 @@ export function formatSiteResourceAccess( }; } + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + const destination = formatSiteResourceDestinationDisplay({ mode: resource.mode as SiteResourceDestinationInput["mode"], destination: resource.destination, diff --git a/src/lib/launcherResourceDetails.ts b/src/lib/launcherResourceDetails.ts index 9920ceeef..4e77c18b0 100644 --- a/src/lib/launcherResourceDetails.ts +++ b/src/lib/launcherResourceDetails.ts @@ -4,7 +4,7 @@ import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSi export type PublicAuthState = "protected" | "not_protected" | "none"; -const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"]; +const BROWSER_MODES = ["http", "ssh", "rdp", "vnc", "inference"]; export function derivePublicAuthState( mode: string | null, @@ -37,6 +37,10 @@ export function formatPublicResourceType( return resource.ssl ? "HTTPS" : "HTTP"; } + if (resource.mode === "inference") { + return "Inference"; + } + const mode = (resource.mode || "").toLowerCase(); if (mode === "tcp") { return "TCP"; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8453022dd..02a9404e8 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -34,6 +34,8 @@ import type { ListLauncherSitesResponse, ListLauncherViewsResponse } from "@server/routers/launcher/types"; +import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels"; +import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { GetResourcePoliciesResponse, @@ -1776,5 +1778,69 @@ export const launcherQueries = { data: res.data.data }; } + }), + aiModels: ( + orgId: string, + params: + | { + resourceType: "public"; + resourceId: number; + } + | { + resourceType: "site"; + siteResourceId: number; + } + | null + ) => + queryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "AI_MODELS", params] as const, + enabled: params != null, + queryFn: async ({ signal, meta }) => { + if (!params) { + throw new Error("Resource params are required"); + } + + if (params.resourceType === "public") { + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/launcher/resource/${params.resourceId}/ai-models`, + { signal } + ); + return res.data.data; + } + + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/launcher/site-resource/${params.siteResourceId}/ai-models`, + { signal } + ); + return res.data.data; + } + }), + myVirtualApiKeys: (orgId: string, resourceGuid: string | null) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "MY_VIRTUAL_API_KEYS", + resourceGuid + ] as const, + enabled: Boolean(resourceGuid), + queryFn: async ({ signal, meta }) => { + if (!resourceGuid) { + throw new Error("resourceGuid is required"); + } + + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/my-virtual-api-keys?resourceGuid=${encodeURIComponent(resourceGuid)}`, + { signal } + ); + return res.data.data; + } }) };